From 441c953413ac42fbb571a3c22c8f3e9d52266430 Mon Sep 17 00:00:00 2001 From: Pau Aliagas Date: Sun, 18 Oct 2026 12:16:10 +0200 Subject: [PATCH 1/3] tests: an LV2 stress fixture and a child that speaks lv2_measure The LV2 fixture gets "stress": its gain plugin, which breaks on cue (STRESS_FAULT crash, hang or fork), as tests/stress_plugin.c does for CLAP. tests/lv2_probe_child.c takes plugin-qualify's lv2_measure arguments and prints lines of the same shape: `--pin ` gives the integrity block of the bundle on LV2_PATH, and ` rate block cycles repeats` gives one measurement line. It needs no lilv and no interposer. It loads the plugin's library with dlopen, so the plugin's own code runs in it. It refuses a measurement without OMX_STIM=noise, and it reports the interposer as loaded when LD_PRELOAD names rt_interpose.so. tests/rt_interpose.so is an empty library of that name, built beside it. The fixture and the child need the LV2 headers, so the CI test job installs lv2-devel. --- .github/workflows/ci.yml | 2 +- .gitignore | 2 + Makefile | 13 +- tests/lv2_fixture.c | 51 +++- tests/lv2_probe_child.c | 256 ++++++++++++++++++ tests/plugin-hostd-fixture.lv2/manifest.ttl | 5 + .../plugin-hostd-fixture.ttl | 12 + tests/rt_interpose_stub.c | 4 + 8 files changed, 339 insertions(+), 6 deletions(-) create mode 100644 tests/lv2_probe_child.c create mode 100644 tests/rt_interpose_stub.c diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 942e179..581f93c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,7 +39,7 @@ jobs: steps: - name: Install the build tools run: | - dnf install -y --setopt=install_weak_deps=False git dnf5-plugins gcc make python3 pkgconf-pkg-config clap-devel \ + dnf install -y --setopt=install_weak_deps=False git dnf5-plugins gcc make python3 pkgconf-pkg-config clap-devel lv2-devel \ pipewire-jack-audio-connection-kit-devel pipewire pipewire-utils iproute util-linux procps-ng diffutils python3-jsonschema - uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index a12a8ab..06b32d8 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ tests/format-test tests/plugin-hostd-fixture.lv2/*.so /plugin-hostd-probe tests/probe-child +tests/lv2-probe-child +tests/rt_interpose.so diff --git a/Makefile b/Makefile index bd3897b..1f3571a 100644 --- a/Makefile +++ b/Makefile @@ -121,7 +121,7 @@ private-refs: # clean rule clean: - @rm -f src/*.o $(PROG) $(PROBE) tests/probe-child $(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) $(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 @@ -164,7 +164,7 @@ tests/stress.clap: tests/stress_plugin.c $(CC) $(CLAP_CFLAGS) $(CFLAGS) -Werror -shared -o $@ $< # every guard broken on purpose, one at a time: its test must go red -sabotage: $(PROG) $(FAKE) +sabotage: $(PROG) $(FAKE) tests/lv2-probe-child tests/rt_interpose.so $(LV2_FIXTURE) MOD_HOST_DIR=$(MOD_HOST_DIR) FAKE_HOST=./$(FAKE) python3 tests/sabotage.py # the jack server the real-worker tests run over: pipewire's own jack implementation (default) or JACK2's jackd @@ -261,9 +261,16 @@ check-schema: # the probe runner over the stress plugin: crash, hang and a process left behind each give the named outcome in a document # that validates -test-probe: $(PROBE) tests/probe-child tests/stress.clap +test-probe: $(PROBE) tests/probe-child tests/stress.clap tests/lv2-probe-child tests/rt_interpose.so $(LV2_FIXTURE) python3 tests/probe_test.py +# lv2_measure's arguments and lines, with no lilv and no interposer: the LV2 tests of the runner load the fixture with it +tests/lv2-probe-child: tests/lv2_probe_child.c include/plugin-hostd/pin.h + $(CC) -Iinclude $(shell $(PKG_CONFIG) --cflags lv2) $(CFLAGS) -Wall -Wextra -Werror -o $@ $< -ldl + +tests/rt_interpose.so: tests/rt_interpose_stub.c + $(CC) $(CFLAGS) -Werror -shared -o $@ $< + tests/probe-child: tests/probe_child.c $(CC) $(CLAP_CFLAGS) $(CFLAGS) -Wall -Wextra -Werror -o $@ $< -ldl diff --git a/tests/lv2_fixture.c b/tests/lv2_fixture.c index 416f297..1e9005a 100644 --- a/tests/lv2_fixture.c +++ b/tests/lv2_fixture.c @@ -4,12 +4,16 @@ * 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 * 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). The ports, by index: 0 in, 1 out, 2 gain (a - * factor), 3 events (atom-in only). */ + * 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 + * (atom-in only). */ +#include #include +#include #include #include +#include #include @@ -56,6 +60,48 @@ static void run(LV2_Handle instance, uint32_t frames) f->out[n] = f->in[n] * gain; } +/* stress: the gain, with the fault STRESS_FAULT names on its first run() in the process, as tests/stress_plugin.c does + * for CLAP: crash aborts, hang spins forever, fork leaves a process of its own session behind (its pid written to the + * file STRESS_FORK_PIDFILE names) */ +static void fault_once(void) +{ + static int done; + const char *fault = getenv("STRESS_FAULT"); + + if (done || !fault) + return; + done = 1; + if (!strcmp(fault, "crash")) + abort(); + if (!strcmp(fault, "hang")) + for (;;) + ; + if (!strcmp(fault, "fork") && fork() == 0) + { + const char *pidfile = getenv("STRESS_FORK_PIDFILE"); + + setsid(); + if (pidfile) + { + FILE *f = fopen(pidfile, "w"); + + if (f) + { + fprintf(f, "%d\n", (int)getpid()); + fclose(f); + } + } + sleep(60); + _exit(0); + } +} + +static void run_stress(LV2_Handle instance, uint32_t frames) +{ + fault_once(); + run(instance, frames); +} + static void cleanup(LV2_Handle instance) { free(instance); @@ -64,6 +110,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 "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/lv2_probe_child.c b/tests/lv2_probe_child.c new file mode 100644 index 0000000..f0ed372 --- /dev/null +++ b/tests/lv2_probe_child.c @@ -0,0 +1,256 @@ +/* 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 + */ + +/* The smallest child the probe runner's LV2 tests can run a plugin with: it takes plugin-qualify's lv2_measure + * arguments and prints lines of the same shape, with no lilv and no interposer. + * + * lv2-probe-child --pin the integrity block; exit 3 for no such plugin + * lv2-probe-child [rate] [block] [cycles] [repeats] one measurement line + * + * The bundle is the directory on LV2_PATH whose manifest.ttl names ; its library is the manifest's lv2:binary, + * loaded with dlopen. The plugin's own code runs here: a plugin that crashes, hangs or leaves a process behind in run() + * (tests/lv2_fixture.c's "stress", STRESS_FAULT) does it in this process, as it would in lv2_measure. The layout pin is + * a fixed digest: the runner copies it and computes none. A measurement refuses to run without OMX_STIM=noise, and + * reports the interposer as loaded when LD_PRELOAD names rt_interpose.so, so a test sees what the runner set. */ + +#ifndef _GNU_SOURCE +#define _GNU_SOURCE +#endif +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#define FILES_MAX 16 + +static char g_bundle[PATH_MAX], g_library[PATH_MAX]; + +static char *slurp(const char *path) +{ + FILE *f = fopen(path, "rb"); + char *text; + long size; + + if (!f) + return NULL; + fseek(f, 0, SEEK_END); + size = ftell(f); + fseek(f, 0, SEEK_SET); + text = calloc(1, (size_t)size + 1); + if (text && fread(text, 1, (size_t)size, f) != (size_t)size) + { + free(text); + text = NULL; + } + fclose(f); + return text; +} + +/* the bundle on LV2_PATH whose manifest names , and the library its lv2:binary names */ +static int find(const char *uri) +{ + char *path = strdup(getenv("LV2_PATH") ? getenv("LV2_PATH") : ""), *dir, *save = NULL, needle[1024]; + int found = 0; + + snprintf(needle, sizeof(needle), "<%s>", uri); + for (dir = strtok_r(path, ":", &save); dir && !found; dir = strtok_r(NULL, ":", &save)) + { + DIR *d = opendir(dir); + struct dirent *e; + + while (d && !found && (e = readdir(d))) + { + char manifest[PATH_MAX], *text, *at, *bin; + + if (e->d_name[0] == '.') + continue; + snprintf(manifest, sizeof(manifest), "%s/%s/manifest.ttl", dir, e->d_name); + text = slurp(manifest); + if (text && (at = strstr(text, needle)) && (bin = strstr(at, "lv2:binary <"))) + { + char *end = strchr(bin + 12, '>'); + + if (end) + { + snprintf(g_bundle, sizeof(g_bundle), "%s/%s", dir, e->d_name); + snprintf(g_library, sizeof(g_library), "%.*s", (int)(end - bin - 12), bin + 12); + found = 1; + } + } + free(text); + } + if (d) + closedir(d); + } + free(path); + return found; +} + +static int by_name(const void *a, const void *b) +{ + return strcmp(*(char *const *)a, *(char *const *)b); +} + +static int pin_only(const char *uri) +{ + DIR *d; + struct dirent *e; + char *names[FILES_MAX]; + int n = 0, i; + + printf("{\"uri\":\"%s\",\"found\":%s", uri, find(uri) ? "true" : "false"); + if (!g_bundle[0]) + { + printf("}\n"); + return 3; + } + d = opendir(g_bundle); + while (d && (e = readdir(d)) && n < FILES_MAX) + if (strstr(e->d_name, ".ttl") || !strcmp(e->d_name, g_library)) + names[n++] = strdup(e->d_name); + if (d) + closedir(d); + qsort(names, (size_t)n, sizeof(names[0]), by_name); + printf(",\"integrity\":{\"files\":["); + for (i = 0; i < n; i++) + { + char path[PATH_MAX], sha[PHD_SHA256_HEX_LEN + 1] = ""; + int fd; + + snprintf(path, sizeof(path), "%s/%s", g_bundle, names[i]); + fd = open(path, O_RDONLY | O_CLOEXEC); + if (fd >= 0) + { + phd_pin_fd_digest(fd, sha); + close(fd); + } + printf("%s{\"path\":\"%s\",\"sha256\":\"%s\"}", i ? "," : "", names[i], sha); + free(names[i]); + } + printf("],\"layout\":{\"scheme\":\"%s\",\"sha256\":\"%064d\"}}}\n", PHD_PIN_LAYOUT_SCHEME, 0); + return 0; +} + +static int threads(void) +{ + DIR *d = opendir("/proc/self/task"); + struct dirent *e; + int n = 0; + + while (d && (e = readdir(d))) + if (e->d_name[0] != '.') + n++; + if (d) + closedir(d); + return n; +} + +int main(int argc, char **argv) +{ + const char *uri; + double rate; + unsigned block; + int cycles, repeats, c, r, b, instantiated = 0, before = threads(), after_first = 0; + char library[PATH_MAX * 2]; + void *lib; + const LV2_Descriptor *(*entry)(uint32_t); + const LV2_Descriptor *desc = NULL; + static float in[8192], out[8192]; + float gain = 1.0f; + uint32_t i; + + if (argc == 3 && !strcmp(argv[1], "--pin")) + return pin_only(argv[2]); + if (argc < 2 || argv[1][0] == '-') + return 1; + uri = argv[1]; + rate = argc > 2 ? atof(argv[2]) : 192000.0; + block = argc > 3 ? (unsigned)atoi(argv[3]) : 512; + cycles = argc > 4 ? atoi(argv[4]) : 20; + repeats = argc > 5 ? atoi(argv[5]) : 3; + /* the runner measures as plugin-qualify does: the noise stimulus, under the interposer beside this program */ + if (!getenv("OMX_STIM") || strcmp(getenv("OMX_STIM"), "noise")) + { + fprintf(stderr, "lv2-probe-child: OMX_STIM is not noise\n"); + return 1; + } + if (block == 0 || block > 8192 || !find(uri)) + { + printf("{\"uri\":\"%s\",\"found\":false}\n", uri); + return 3; + } + snprintf(library, sizeof(library), "%s/%s", g_bundle, g_library); + lib = dlopen(library, RTLD_NOW | RTLD_LOCAL); + entry = lib ? (const LV2_Descriptor *(*)(uint32_t))dlsym(lib, "lv2_descriptor") : NULL; + for (i = 0; entry && (desc = entry(i)); i++) + if (!strcmp(desc->URI, uri)) + break; + if (!desc) + { + printf("{\"uri\":\"%s\",\"found\":true,\"instantiated\":false,\"cyclesAttempted\":%d}\n", uri, cycles); + return 2; + } + for (c = 0; c < cycles; c++) + { + LV2_Handle h = desc->instantiate(desc, rate, g_bundle, NULL); + + if (h) + { + instantiated++; + if (c == 0) + after_first = threads(); + desc->cleanup(h); + } + } + for (r = 0; r < repeats; r++) + { + LV2_Handle h = desc->instantiate(desc, rate, g_bundle, NULL); + + if (!h) + break; + desc->connect_port(h, 0, in); + desc->connect_port(h, 1, out); + desc->connect_port(h, 2, &gain); + if (desc->activate) + desc->activate(h); + for (b = 0; b < 64; b++) + desc->run(h, block); + if (desc->deactivate) + desc->deactivate(h); + desc->cleanup(h); + } + printf("{\"uri\":\"%s\",\"found\":true,\"instantiated\":true,\"rate\":%.0f,\"block\":%u,\"audioInputs\":1,\"audioOutputs\":1," + "\"cvPorts\":0,\"controlInputs\":1,\"requiredFeatures\":[],\"optionalFeatures\":[]," + "\"lifecycle\":{\"cycles\":%d,\"instantiated\":%d,\"failed\":%d}," + "\"threads\":{\"before\":%d,\"afterFirstInstantiate\":%d,\"afterAllFreed\":%d,\"leaked\":0}," + "\"rtSafety\":{\"interposer\":%s,\"blocks\":64,\"repeats\":%d,\"swept\":false,\"sweepApplicable\":true," + "\"allocationsInRun\":0,\"allocationsMin\":0,\"variable\":false,\"locksInRun\":0,\"locksMin\":0," + "\"syscallsInRun\":0,\"syscallsMin\":0,\"events\":[]}}\n", + uri, rate, block, cycles, instantiated, cycles - instantiated, before, after_first, threads(), + getenv("LD_PRELOAD") && strstr(getenv("LD_PRELOAD"), "rt_interpose.so") ? "true" : "false", repeats); + return 0; +} diff --git a/tests/plugin-hostd-fixture.lv2/manifest.ttl b/tests/plugin-hostd-fixture.lv2/manifest.ttl index b3474c5..ee09b8b 100644 --- a/tests/plugin-hostd-fixture.lv2/manifest.ttl +++ b/tests/plugin-hostd-fixture.lv2/manifest.ttl @@ -12,3 +12,8 @@ a lv2:Plugin ; lv2:binary ; rdfs:seeAlso . + + + a lv2:Plugin ; + lv2:binary ; + rdfs:seeAlso . diff --git a/tests/plugin-hostd-fixture.lv2/plugin-hostd-fixture.ttl b/tests/plugin-hostd-fixture.lv2/plugin-hostd-fixture.ttl index ec02345..b58a0da 100644 --- a/tests/plugin-hostd-fixture.lv2/plugin-hostd-fixture.ttl +++ b/tests/plugin-hostd-fixture.lv2/plugin-hostd-fixture.ttl @@ -29,3 +29,15 @@ a atom:AtomPort , lv2:InputPort ; atom:bufferType atom:Sequence ; lv2:index 3 ; lv2:symbol "events" ; lv2:name "Events" ] . + + + a lv2:Plugin , lv2:AmplifierPlugin ; + doap:name "plugin-hostd fixture gain that breaks on cue (STRESS_FAULT)" ; + 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 + ] . diff --git a/tests/rt_interpose_stub.c b/tests/rt_interpose_stub.c new file mode 100644 index 0000000..2ddf3a6 --- /dev/null +++ b/tests/rt_interpose_stub.c @@ -0,0 +1,4 @@ +/* SPDX-License-Identifier: GPL-3.0-or-later */ +/* An empty library under the name of plugin-qualify's interposer, rt_interpose.so, built beside tests/lv2-probe-child: + * the runner preloads the file of that name beside its LV2 child, and the fake child reports whether it was. */ +int plugin_hostd_rt_interpose_stub; From 45331d3a1b35501fa9fb2991713ab6c9372ef516 Mon Sep 17 00:00:00 2001 From: Pau Aliagas Date: Sun, 18 Oct 2026 12:16:10 +0200 Subject: [PATCH 2/3] plugin-hostd-probe: run an LV2 plugin through lv2_measure's passes `plugin-hostd-probe run lv2:` measures an LV2 plugin the way plugin-qualify's LV2 hosting pass does today, so stage 1 of the probe output covers LV2 as well as CLAP. The child is plugin-qualify's lv2_measure, looked up at run time in /usr/libexec/plugin-qualify, or the one --child names. plugin-qualify builds against this package, so it is not a dependency of it. The runner drives the passes itself, each a child of its own under the same limits and the same wall clock: - the pin: `lv2_measure --pin `, nothing instantiated. The bundle's files and the layout pin become the document's binary: the library is the binary, and every file is listed. A plugin no bundle holds exits 3 with no document. A plugin of which nothing could be pinned exits 2 with no document, because the document has no binary to name. - the measurement: `lv2_measure 30 3`, under rt_interpose.so beside the child and OMX_STIM=noise, as plugin-qualify runs it (PHD_PROBE_LV2_LIFECYCLES and PHD_PROBE_LV2_REPEATS in protocol.h). Its line becomes the rate's grid point with rtSafety, and the behaviour block with lifecycle, threads and required features. Nothing is judged. run_child now takes its argv and the deadline, and the classification of how a child ended is one function, used for each pass. The child's log is appended to across passes. The tests run the stress fixture through the fake child: a measured run, a crash that keeps the pin, a hang ended at the wall clock, a process left in its own session that is killed, and a plugin no bundle holds. When plugin-qualify's lv2_measure is installed, it measures the gain fixture under its interposer. Six sabotage arms each turn a test red. Refs #76 --- README.md | 14 +- doc/plugin-hostd-probe.1 | 10 +- include/plugin-hostd/protocol.h | 5 + src/probe.c | 438 ++++++++++++++++++++++++++------ tests/probe_test.py | 111 ++++++++ tests/sabotage.py | 13 + 6 files changed, 516 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index a58f191..f3189ae 100644 --- a/README.md +++ b/README.md @@ -303,9 +303,21 @@ Probing a plugin ---------------- `plugin-hostd-probe run` loads a plugin in a child process under limits and writes down what happened, as facts only: no -verdict. The child is clap-qualify today; the runner never loads plugin code itself. +verdict. The child is clap-qualify for a CLAP plugin. For an LV2 plugin, named `lv2:`, the child is plugin-qualify's +`lv2_measure`, found at run time in `/usr/libexec/plugin-qualify` (plugin-qualify is not a dependency of this package). +The runner never loads plugin code itself. plugin-hostd-probe run --out probe.json --log probe.stderr.log /usr/lib64/clap/plate.clap org.example.plate + plugin-hostd-probe run --out probe.json lv2:http://example.org/plate + +An LV2 plugin is measured the way plugin-qualify's LV2 hosting pass measures it today, in two children under the same +limits and the same wall clock. The first child (`lv2_measure --pin`) pins the bundle's files without instantiating the +plugin. The second measures at the highest of `--rates`, in blocks of `--quantum`, with 30 instantiate and destroy cycles +and 3 swept rt-safety runs. It runs under plugin-qualify's interposer (`rt_interpose.so` beside the child) and the noise +stimulus. The document's binary is the plugin's library, with every pinned file of the bundle listed. The rate's grid +point carries what run() did under the interposer, and `behaviour` carries the lifecycles, the threads and the features +the plugin requires. A plugin no bundle holds exits 3 with no document. A plugin of which nothing could be pinned exits 2 +with no document, because the document has no binary to name. The runner kills the whole process tree of the child at its wall clock, and again when the child is done, so a process a plugin started in a session of its own does not outlive the run. It applies these limits on every Linux: the wall clock, diff --git a/doc/plugin-hostd-probe.1 b/doc/plugin-hostd-probe.1 index 28355ff..32184eb 100644 --- a/doc/plugin-hostd-probe.1 +++ b/doc/plugin-hostd-probe.1 @@ -6,9 +6,15 @@ plugin\-hostd\-probe \- run a plugin prober under limits and write what happened .RI [ options ] .I plugin.clap .RI [ "clap id" ] +.br +.B plugin\-hostd\-probe run +.RI [ options ] +.BI lv2: uri .SH DESCRIPTION .B plugin\-hostd\-probe -starts a program that loads a plugin (clap\-qualify by default) in its own process group, with the portable +starts a program that loads a plugin (clap\-qualify for a CLAP, plugin\-qualify's lv2_measure for an LV2 named +.BI lv2: uri , +the pin pass and then the measurement at the highest rate, under its interposer) in its own process group, with the portable limits applied before the exec, and writes the facts of the run as a JSON document: the steps the child reached, how each ended and the limits that were applied. It gives no verdict. The runner never loads plugin code itself. On the deadline, on a cancel or when the child is done it kills the whole process tree, including a process the plugin @@ -20,7 +26,7 @@ Nothing a plugin prints can enter it. .SH OPTIONS .TP .BI \-\-child " program" -The program that loads the plugin. Default clap\-qualify. +The program that loads the plugin. Default clap\-qualify, and /usr/libexec/plugin\-qualify/lv2_measure for an LV2. .TP .BI \-\-rates " R,R,..." Sample rates to run at. Default 44100,48000,96000,192000. diff --git a/include/plugin-hostd/protocol.h b/include/plugin-hostd/protocol.h index fda5b63..a0f93f6 100644 --- a/include/plugin-hostd/protocol.h +++ b/include/plugin-hostd/protocol.h @@ -119,6 +119,11 @@ #define PHD_PROBE_FORMAT_MINOR 0 #define PHD_PROBE_PROTOCOL_NAME "plugin-hostd.probe-protocol" #define PHD_PROBE_PROTOCOL_VERSION 1 +/* version 1 of the probe protocol runs an LV2 plugin as plugin-qualify's LV2 hosting pass does today + * (LV2_HOSTING_LIFECYCLES, LV2_HOSTING_REPEATS), so the verdicts from its documents equal today's: this many + * instantiate/destroy cycles and swept rt-safety runs, at the highest rate asked for */ +#define PHD_PROBE_LV2_LIFECYCLES 30 +#define PHD_PROBE_LV2_REPEATS 3 /* how a step, a grid point or the whole run ended: X(id, code, meaning) */ #define PHD_PROBE_OUTCOMES(X) \ diff --git a/src/probe.c b/src/probe.c index abd5162..91bf59e 100644 --- a/src/probe.c +++ b/src/probe.c @@ -22,6 +22,7 @@ * the probe output (protocol/probe-output.schema.json): facts only, no verdict. * * plugin-hostd-probe run [options] [] + * plugin-hostd-probe run [options] lv2: plugin-qualify's lv2_measure, its pin pass then its measurement * * The runner never loads plugin code. It starts the child in its own process group, with the portable limits below * applied before the exec, keeps the wall clock, and on the deadline, on a cancel or when the child is done kills the @@ -1070,21 +1071,11 @@ static void take_step_records(run_t *r) r->steps.s[r->steps.len] = '\0'; } -static void run_child(const options_t *o, const cpu_set_t *pinned, int pin_option, run_t *r) +/* clap-qualify's arguments: the rates, the block, the offer, the file and the id */ +static void clap_argv(const options_t *o, char **argv) { - int out_pipe[2], steps_pipe[2], status_pipe[2]; - char *argv[32]; - int argc = 0, devnull; - long deadline; - int log_fd = -1; + int argc = 0; - if (pipe2(out_pipe, O_CLOEXEC) || pipe2(steps_pipe, O_CLOEXEC) || pipe2(status_pipe, O_CLOEXEC)) - { - perror("pipe"); - exit(2); - } - if (o->log) - log_fd = open(o->log, O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0644); argv[argc++] = (char *)o->child; argv[argc++] = "--rates"; argv[argc++] = (char *)o->rates; @@ -1110,6 +1101,22 @@ static void run_child(const options_t *o, const cpu_set_t *pinned, int pin_optio if (o->id) argv[argc++] = (char *)o->id; argv[argc] = NULL; +} + +/* the child `argv` under the limits, until it ends or `deadline` (ms of now_ms) passes; its log is appended to */ +static void run_child(const options_t *o, char **argv, const cpu_set_t *pinned, int pin_option, run_t *r, long deadline) +{ + int out_pipe[2], steps_pipe[2], status_pipe[2]; + int devnull; + int log_fd = -1; + + if (pipe2(out_pipe, O_CLOEXEC) || pipe2(steps_pipe, O_CLOEXEC) || pipe2(status_pipe, O_CLOEXEC)) + { + perror("pipe"); + exit(2); + } + if (o->log) + log_fd = open(o->log, O_WRONLY | O_CREAT | O_APPEND | O_CLOEXEC, 0644); r->started_ms = now_ms(); r->pid = fork(); @@ -1159,7 +1166,6 @@ static void run_child(const options_t *o, const cpu_set_t *pinned, int pin_optio close(status_pipe[0]); } - deadline = r->started_ms + o->wall_s * 1000; while (!r->exited) { struct pollfd p[2]; @@ -1270,6 +1276,246 @@ static int outcome_ok(const char *code) return !strcmp(code, OUT_OK) || !strcmp(code, OUT_NOT_ATTEMPTED); } +/* how the child ended, as the step `name`: its outcome, and whether the run may still be complete */ +static const char *child_end(const char *name, const options_t *o, const run_t *r, int *complete) +{ + { + const char *code = OUT_OK; + step_t *s = add_step(name, OUT_OK, now_ms() - r->started_ms); + + if (r->protocol_error) + code = OUT_CHILD_PROTOCOL; + else if (r->cancelled) + code = OUT_CANCELLED; + else if (r->killed_by_deadline) + code = OUT_TIMEOUT; + else if (WIFSIGNALED(r->status)) + { + int sig = WTERMSIG(r->status); + + snprintf(s->signal, sizeof(s->signal), "%s", signal_name(sig)); + code = sig == SIGXCPU ? OUT_CPU_LIMIT : sig == SIGXFSZ ? OUT_OUTPUT_LIMIT : sig == SIGSYS ? OUT_SANDBOX_DENIED : OUT_CRASHED; + } + else if (WIFEXITED(r->status)) + { + s->exit_status = WEXITSTATUS(r->status); + if (s->exit_status == 120) + { + code = OUT_ERROR; + set_detail(s, "a limit could not be applied in the child"); + } + else if (s->exit_status != 0 && r->out.len == 0) + code = OUT_ERROR; + } + s->outcome = code; + if (r->killed_by_deadline) + set_detail(s, "the wall clock of %ld s passed; the process tree was killed", o->wall_s); + if (r->cancelled) + set_detail(s, "the runner was told to stop; the process tree was killed"); + if (r->leftovers && !s->detail[0]) + set_detail(s, "%d process(es) left behind were killed", r->leftovers); + else if (r->leftovers) + snprintf(s->detail + strlen(s->detail), sizeof(s->detail) - strlen(s->detail), "; %d process(es) left behind were killed", r->leftovers); + /* a crash keeps the steps before it; the run is complete only when the child finished */ + *complete = !strcmp(code, OUT_OK) && WIFEXITED(r->status); + return code; + } +} + +/* an LV2 plugin is named lv2:, and measured by plugin-qualify's lv2_measure, looked up where plugin-qualify installs + * it; plugin-qualify is no dependency of this package (it builds against it), so the program is looked for at run time */ +#define PROBE_LV2_PREFIX "lv2:" +#define PROBE_LV2_CHILD "/usr/libexec/plugin-qualify/lv2_measure" +/* the measurement runs as plugin-qualify runs it: under its allocation-and-syscall interposer, the file of this name + * beside the child (none beside it: rtSafety says interposer false), and the noise stimulus */ +#define PROBE_LV2_INTERPOSER "rt_interpose.so" +#define PROBE_LV2_STIMULUS_ENV "OMX_STIM" +#define PROBE_LV2_STIMULUS "noise" + +static char g_interposer[PATH_MAX]; + +static jnode_t *first_line(sb_t *out) +{ + if (out->len == 0) + return NULL; + out->s[strcspn(out->s, "\n")] = '\0'; + return jparse(out->s); +} + +static int has_suffix(const char *s, const char *suffix) +{ + size_t n = strlen(s), m = strlen(suffix); + + return n >= m && !strcmp(s + n - m, suffix); +} + +/* lv2_measure's passes as today's LV2 hosting pass runs them: the pin (--pin, nothing instantiated), then one measurement + * at `rate` in blocks of the run's quantum, PHD_PROBE_LV2_LIFECYCLES instantiate/destroy cycles and + * PHD_PROBE_LV2_REPEATS swept rt-safety runs. Each pass is a child of its own under the same limits and the one wall + * clock. 3 when no bundle holds the plugin (no document); else 0, with *library the pinned file that is the plugin's + * library, NULL when nothing was pinned. */ +static int run_lv2(const options_t *o, const char *uri, long rate, const cpu_set_t *pinned, int pin_option, long deadline, run_t *r, + jnode_t **pin, jnode_t **measure, const jnode_t **library, const char **outcome, int *complete) +{ + char *argv[8], rate_s[24], block_s[24], cycles_s[24], repeats_s[24]; + const jnode_t *integrity, *files; + size_t k; + + argv[0] = (char *)o->child; + argv[1] = "--pin"; + argv[2] = (char *)uri; + argv[3] = NULL; + run_child(o, argv, pinned, pin_option, r, deadline); + *pin = first_line(&r->out); + if (!r->killed_by_deadline && !r->cancelled && WIFEXITED(r->status) && WEXITSTATUS(r->status) == 3 && + !jbool(*pin, "found", 1)) + { + fprintf(stderr, RUNNER_NAME ": %s%s: %s\n", PROBE_LV2_PREFIX, uri, OUT_NOT_FOUND); + return 3; + } + *outcome = child_end("hash", o, r, complete); + integrity = jget(*pin, "integrity"); + files = jget(integrity, "files"); + for (k = 0; files && files->type == J_ARR && k < files->n; k++) + if (jstr(files->kid[k], "path") && !has_suffix(jstr(files->kid[k], "path"), ".ttl")) + { + *library = files->kid[k]; + break; + } + if (!strcmp(*outcome, OUT_OK) && *library == NULL) + { + step_t *s = &g_steps[g_nsteps - 1]; + const char *reason = jstr(integrity, "unmeasuredReason"), *detail = jstr(integrity, "unmeasuredDetail"); + + s->outcome = *outcome = reason && !strcmp(reason, "unpinnable") ? OUT_UNPINNABLE : OUT_ERROR; + set_detail(s, "%s", detail ? detail : "the child pinned no library of the plugin"); + *complete = 0; + } + if (strcmp(*outcome, OUT_OK)) + return 0; + + snprintf(rate_s, sizeof(rate_s), "%ld", rate); + snprintf(block_s, sizeof(block_s), "%u", o->quantum); + snprintf(cycles_s, sizeof(cycles_s), "%d", PHD_PROBE_LV2_LIFECYCLES); + snprintf(repeats_s, sizeof(repeats_s), "%d", PHD_PROBE_LV2_REPEATS); + argv[1] = (char *)uri; + argv[2] = rate_s; + argv[3] = block_s; + argv[4] = cycles_s; + argv[5] = repeats_s; + argv[6] = NULL; + { + char dir[PATH_MAX]; + const char *slash; + + snprintf(dir, sizeof(dir), "%s", o->child); + slash = strrchr(dir, '/'); + if (slash) + { + snprintf(g_interposer, sizeof(g_interposer), "%.*s/" PROBE_LV2_INTERPOSER, (int)(slash - dir), dir); + if (access(g_interposer, R_OK) == 0) + setenv("LD_PRELOAD", g_interposer, 1); + else + g_interposer[0] = '\0'; + } + setenv(PROBE_LV2_STIMULUS_ENV, PROBE_LV2_STIMULUS, 1); + } + memset(r, 0, sizeof(*r)); + run_child(o, argv, pinned, pin_option, r, deadline); + unsetenv("LD_PRELOAD"); + *measure = first_line(&r->out); + if (*measure && jbool(*measure, "instantiated", 0)) + { + const jnode_t *rt = jget(*measure, "rtSafety"); + step_t *s = add_step("instantiate", OUT_OK, 0); + + set_detail(s, "read from the child's line"); + s = add_step("rt-safety", jbool(rt, "interposer", 0) ? OUT_OK : OUT_NOT_ATTEMPTED, 0); + s->rate = rate; + s->quantum = o->quantum; + set_detail(s, jbool(rt, "interposer", 0) ? "read from the child's line" : "no interposer was loaded"); + } + else if (*measure && jbool(*measure, "found", 0)) + { + step_t *s = add_step("instantiate", OUT_REFUSED_INSTANTIATION, 0); + + set_detail(s, "the child could not instantiate the plugin"); + } + *outcome = child_end("lifecycle", o, r, complete); + return 0; +} + +static void put_count(sb_t *b, const jnode_t *o, const char *from, const char *to) +{ + double v; + + if (jnum(o, from, &v)) + sb_printf(b, ", \"%s\": %.0f", to, v); +} + +/* lv2_measure's measurement line: the grid point of its rate and block, with what run() did under the interposer, and + * the plugin's behaviour over the pass (lifecycles, threads, the features it requires); a translation, no judgement */ +static void put_lv2_measure(sb_t *b, const jnode_t *m, long rate, unsigned quantum) +{ + const jnode_t *rt = jget(m, "rtSafety"), *ev = jget(rt, "events"), *life = jget(m, "lifecycle"), *th = jget(m, "threads"), + *req = jget(m, "requiredFeatures"); + size_t k; + + sb_printf(b, ",\n \"grid\": [\n {\"rate\": %ld, \"quantum\": %u, \"outcome\": \"%s\"", rate, quantum, OUT_OK); + if (rt) + { + sb_printf(b, ", \"rtSafety\": {\"interposer\": %s", jbool(rt, "interposer", 0) ? "true" : "false"); + sb_printf(b, ", \"swept\": %s, \"sweepApplicable\": %s", jbool(rt, "swept", 0) ? "true" : "false", + jbool(rt, "sweepApplicable", 0) ? "true" : "false"); + put_count(b, rt, "blocks", "blocks"); + put_count(b, rt, "allocationsInRun", "allocations"); + put_count(b, rt, "locksInRun", "locks"); + put_count(b, rt, "syscallsInRun", "syscalls"); + if (ev && ev->type == J_ARR) + { + sb_printf(b, ", \"events\": ["); + for (k = 0; k < ev->n; k++) + { + char text[512]; + const char *kind = jstr(ev->kid[k], "kind"), *module = jstr(ev->kid[k], "module"), *symbol = jstr(ev->kid[k], "symbol"); + + snprintf(text, sizeof(text), "%s %s %s", kind ? kind : "?", module ? module : "?", symbol ? symbol : "?"); + sb_printf(b, "%s", k ? ", " : ""); + sb_str(b, text); + } + sb_printf(b, "]"); + } + sb_printf(b, "}"); + } + sb_printf(b, "}\n ],\n \"behaviour\": {"); + if (life) + { + double c = 0, i = 0, f = 0; + + jnum(life, "cycles", &c); + jnum(life, "instantiated", &i); + jnum(life, "failed", &f); + sb_printf(b, "\"lifecycle\": {\"cycles\": %.0f, \"instantiated\": %.0f, \"failed\": %.0f}, ", c, i, f); + } + sb_printf(b, "\"threads\": {"); + { + sb_t t = { 0 }; + + put_count(&t, th, "before", "before"); + put_count(&t, th, "afterFirstInstantiate", "afterFirstInstantiate"); + put_count(&t, th, "afterAllFreed", "afterAllFreed"); + sb_printf(b, "%s", t.len > 2 ? t.s + 2 : ""); + free(t.s); + } + sb_printf(b, "}, \"features\": {\"required\": ["); + for (k = 0; req && req->type == J_ARR && k < req->n; k++) + { + sb_printf(b, "%s", k ? ", " : ""); + sb_str(b, req->kid[k]->str ? req->kid[k]->str : ""); + } + sb_printf(b, "]}}"); +} + static const char *base_name(const char *path) { const char *s = strrchr(path, '/'); @@ -1292,12 +1538,19 @@ int main(int argc, char **argv) sb_t doc = { 0 }; jnode_t *line = NULL; const char *run_outcome = OUT_OK; + /* an LV2 plugin, lv2:: the runner runs lv2_measure's passes, the pin then the measurement */ + const char *lv2_uri = NULL; + jnode_t *pin_line = NULL, *measure_line = NULL; + const jnode_t *lv2_library = NULL; + long lv2_rate = 0; + int child_set = 0; int complete = 1, fd; if (argc < 2 || strcmp(argv[1], "run")) { fprintf(stderr, "usage: " RUNNER_NAME " run [options] []\n" - " --child PROGRAM the program that loads the plugin (default clap-qualify)\n" + " " RUNNER_NAME " run [options] lv2:\n" + " --child PROGRAM the program that loads the plugin (default clap-qualify; " PROBE_LV2_CHILD " for lv2:)\n" " --rates R,R,... sample rates (default 44100,48000,96000,192000)\n" " --quantum N block size in frames (default 512) --blocks N blocks per rate\n" " --offer EXT,... host extensions to offer on top of the child's own\n" @@ -1322,7 +1575,7 @@ int main(int argc, char **argv) return 2; } a++; - if (!strcmp(k, "--child")) o.child = v; + if (!strcmp(k, "--child")) o.child = v, child_set = 1; else if (!strcmp(k, "--rates")) o.rates = v; else if (!strcmp(k, "--quantum")) o.quantum = (unsigned)atoi(v); else if (!strcmp(k, "--blocks")) o.blocks = (unsigned)atoi(v); @@ -1362,18 +1615,44 @@ int main(int argc, char **argv) fprintf(stderr, RUNNER_NAME ": --reason %s is not one of the reasons of the format\n", o.reason); return 2; } - if (stat(o.plugin, &st) != 0 || !S_ISREG(st.st_mode) || realpath(o.plugin, realpath_buf) == NULL) + if (!strncmp(o.plugin, PROBE_LV2_PREFIX, strlen(PROBE_LV2_PREFIX))) + { + char *end; + + lv2_uri = o.plugin + strlen(PROBE_LV2_PREFIX); + if (!child_set) + o.child = PROBE_LV2_CHILD; + /* today's LV2 pass measures at the highest rate asked for */ + for (end = (char *)o.rates; *end;) + { + long v = strtol(end, &end, 10); + + if (v > lv2_rate) + lv2_rate = v; + if (*end == ',') + end++; + else if (*end) + break; + } + if (!*lv2_uri || lv2_rate <= 0) + { + fprintf(stderr, RUNNER_NAME ": %s: an LV2 plugin is lv2:, measured at a positive rate\n", o.plugin); + return 2; + } + } + else if (stat(o.plugin, &st) != 0 || !S_ISREG(st.st_mode) || realpath(o.plugin, realpath_buf) == NULL) { fprintf(stderr, RUNNER_NAME ": %s: " "%s\n", o.plugin, OUT_NOT_FOUND); return 3; } - fd = open(realpath_buf, O_RDONLY | O_CLOEXEC); - if (fd < 0 || phd_pin_fd_digest(fd, sha) != 0) + fd = lv2_uri ? -1 : open(realpath_buf, O_RDONLY | O_CLOEXEC); + if (!lv2_uri && (fd < 0 || phd_pin_fd_digest(fd, sha) != 0)) { fprintf(stderr, RUNNER_NAME ": %s: cannot be read\n", realpath_buf); return 2; } - close(fd); + if (fd >= 0) + close(fd); sched_getaffinity(0, sizeof(pinned), &pinned); if (o.cpus) @@ -1421,62 +1700,40 @@ int main(int argc, char **argv) } t0 = now_ms(); + if (o.log) { - step_t *s = add_step("hash", OUT_OK, now_ms() - t0); + int lfd = open(o.log, O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0644); - (void)s; + if (lfd >= 0) + close(lfd); } - memset(&r, 0, sizeof(r)); read_idle(g_idle_before, &g_nidle, &pinned); - run_child(&o, &pinned, pin_option, &r); - read_idle(g_idle_after, &g_nidle, &pinned); - - /* how the child ended */ + if (lv2_uri) { - const char *code = OUT_OK; - step_t *s = add_step("lifecycle", OUT_OK, now_ms() - r.started_ms); - - if (r.protocol_error) - code = OUT_CHILD_PROTOCOL; - else if (r.cancelled) - code = OUT_CANCELLED; - else if (r.killed_by_deadline) - code = OUT_TIMEOUT; - else if (WIFSIGNALED(r.status)) - { - int sig = WTERMSIG(r.status); - - snprintf(s->signal, sizeof(s->signal), "%s", signal_name(sig)); - code = sig == SIGXCPU ? OUT_CPU_LIMIT : sig == SIGXFSZ ? OUT_OUTPUT_LIMIT : sig == SIGSYS ? OUT_SANDBOX_DENIED : OUT_CRASHED; - } - else if (WIFEXITED(r.status)) + if (run_lv2(&o, lv2_uri, lv2_rate, &pinned, pin_option, t0 + o.wall_s * 1000, &r, &pin_line, &measure_line, + &lv2_library, &run_outcome, &complete) == 3) + return 3; + if (lv2_library == NULL) { - s->exit_status = WEXITSTATUS(r.status); - if (s->exit_status == 120) - { - code = OUT_ERROR; - set_detail(s, "a limit could not be applied in the child"); - } - else if (s->exit_status != 0 && r.out.len == 0) - code = OUT_ERROR; + fprintf(stderr, RUNNER_NAME ": %s: no file of the plugin was pinned, so the document has no binary (%s)\n", + o.plugin, run_outcome); + return 2; } - s->outcome = code; - if (r.killed_by_deadline) - set_detail(s, "the wall clock of %ld s passed; the process tree was killed", o.wall_s); - if (r.cancelled) - set_detail(s, "the runner was told to stop; the process tree was killed"); - if (r.leftovers && !s->detail[0]) - set_detail(s, "%d process(es) left behind were killed", r.leftovers); - else if (r.leftovers) - snprintf(s->detail + strlen(s->detail), sizeof(s->detail) - strlen(s->detail), "; %d process(es) left behind were killed", r.leftovers); - run_outcome = code; - /* a crash keeps the steps before it; the run is complete only when the child finished */ - complete = !strcmp(code, OUT_OK) && WIFEXITED(r.status); } + else + { + char *argv_child[32]; + + add_step("hash", OUT_OK, now_ms() - t0); + clap_argv(&o, argv_child); + run_child(&o, argv_child, &pinned, pin_option, &r, t0 + o.wall_s * 1000); + run_outcome = child_end("lifecycle", &o, &r, &complete); + } + read_idle(g_idle_after, &g_nidle, &pinned); /* the child's facts, when it printed them */ - if (r.out.len > 0) + if (!lv2_uri && r.out.len > 0) { r.out.s[strcspn(r.out.s, "\n")] = '\0'; line = jparse(r.out.s); @@ -1560,13 +1817,48 @@ int main(int argc, char **argv) PHD_PROBE_PROTOCOL_VERSION); sb_printf(&doc, " \"tools\": {\"runner\": \"" RUNNER_NAME " %s\", \"child\": ", PROBE_VERSION); sb_str(&doc, base_name(o.child)); - sb_printf(&doc, "},\n \"via\": \"runner\"\n },\n \"plugin\": {\"format\": \"clap\", \"id\": "); - sb_str(&doc, plugin_id ? plugin_id : o.id ? o.id : base_name(realpath_buf)); - sb_printf(&doc, "},\n \"binary\": {\"path\": "); - sb_str(&doc, realpath_buf); - sb_printf(&doc, ", \"sha256\": \"%s\", \"files\": [{\"path\": ", sha); - sb_str(&doc, base_name(o.plugin)); - sb_printf(&doc, ", \"sha256\": \"%s\"}]", sha); + if (g_interposer[0]) + { + sb_printf(&doc, ", \"interposer\": "); + sb_str(&doc, base_name(g_interposer)); + } + sb_printf(&doc, "},\n \"via\": \"runner\"\n },\n \"plugin\": {\"format\": \"%s\", \"id\": ", lv2_uri ? "lv2" : "clap"); + if (lv2_uri) + { + const jnode_t *integ_lv2 = jget(pin_line, "integrity"), *files = jget(integ_lv2, "files"); + + sb_str(&doc, lv2_uri); + if (jstr(pin_line, "version")) + { + sb_printf(&doc, ", \"version\": "); + sb_str(&doc, jstr(pin_line, "version")); + } + /* the plugin's library is the binary; every file of the pin is listed, each with its digest */ + sb_printf(&doc, "},\n \"binary\": {\"path\": "); + sb_str(&doc, jstr(lv2_library, "path")); + sb_printf(&doc, ", \"sha256\": "); + sb_str(&doc, jstr(lv2_library, "sha256")); + sb_printf(&doc, ", \"files\": ["); + for (k = 0; files && k < files->n; k++) + { + sb_printf(&doc, "%s{\"path\": ", k ? ", " : ""); + sb_str(&doc, jstr(files->kid[k], "path") ? jstr(files->kid[k], "path") : ""); + sb_printf(&doc, ", \"sha256\": "); + sb_str(&doc, jstr(files->kid[k], "sha256") ? jstr(files->kid[k], "sha256") : ""); + sb_printf(&doc, "}"); + } + sb_printf(&doc, "]"); + layout_sha = jstr(jget(integ_lv2, "layout"), "sha256"); + } + else + { + sb_str(&doc, plugin_id ? plugin_id : o.id ? o.id : base_name(realpath_buf)); + sb_printf(&doc, "},\n \"binary\": {\"path\": "); + sb_str(&doc, realpath_buf); + sb_printf(&doc, ", \"sha256\": \"%s\", \"files\": [{\"path\": ", sha); + sb_str(&doc, base_name(o.plugin)); + sb_printf(&doc, ", \"sha256\": \"%s\"}]", sha); + } if (layout_sha && strlen(layout_sha) == PHD_SHA256_HEX_LEN) sb_printf(&doc, ", \"layout\": {\"scheme\": \"%s\", \"sha256\": \"%s\"}", PHD_PIN_LAYOUT_SCHEME, layout_sha); if (unmeasured && !strcmp(unmeasured, "unpinnable")) @@ -1683,6 +1975,8 @@ int main(int argc, char **argv) } sb_printf(&doc, "\n ]"); } + if (lv2_uri && measure_line && jbool(measure_line, "instantiated", 0)) + put_lv2_measure(&doc, measure_line, lv2_rate, o.quantum); if (WIFSIGNALED(r.status) && !r.killed_by_deadline && !r.cancelled) { sb_printf(&doc, ",\n \"crash\": {\"signal\": \"%s\"}", signal_name(WTERMSIG(r.status))); diff --git a/tests/probe_test.py b/tests/probe_test.py index f5cdba5..a3a342b 100644 --- a/tests/probe_test.py +++ b/tests/probe_test.py @@ -169,6 +169,117 @@ def the_limits_reach_the_child(): check(doc["machine"]["pinnedCpus"] == str(cpus[0]), "pinnedCpus %s" % doc["machine"]["pinnedCpus"]) +# ---------------------------------------------------------------- LV2: the runner drives lv2_measure's passes + +LV2_CHILD = os.path.abspath(os.environ.get("PROBE_LV2_CHILD", os.path.join(ROOT, "tests", "lv2-probe-child"))) +LV2_BUNDLES = os.path.abspath(os.environ.get("LV2_FIXTURE_DIR", os.path.join(ROOT, "tests"))) +LV2_STRESS = "urn:plugin-hostd:test:stress" +REAL_LV2_MEASURE = "/usr/libexec/plugin-qualify/lv2_measure" + + +def run_lv2(fault=None, uri=LV2_STRESS, child=LV2_CHILD, wall=5, timeout=30, rates="48000,96000"): + """the runner over the LV2 stress fixture with the fake lv2_measure (or the real one)""" + work = tempfile.mkdtemp(prefix="plugin-hostd-probe-lv2.") + out = os.path.join(work, "probe.json") + pidfile = os.path.join(work, "forkpid") + e = dict(os.environ, STRESS_FORK_PIDFILE=pidfile, LV2_PATH=LV2_BUNDLES) + if fault: + e["STRESS_FAULT"] = fault + else: + e.pop("STRESS_FAULT", None) + cmd = [PROBE, "run", "--child", child, "--rates", rates, "--quantum", "256", "--wall-s", str(wall), "--grace-ms", "200", + "--log", os.path.join(work, "child.log"), "--out", out, "lv2:" + uri] + start = time.monotonic() + try: + p = subprocess.run(cmd, env=e, capture_output=True, text=True, timeout=timeout) + except subprocess.TimeoutExpired: + kill_leftover(pidfile) + raise AssertionError("the runner did not return within %d s" % timeout) + took = time.monotonic() - start + doc = json.load(open(out)) if os.path.exists(out) else None + return p.returncode, doc, took, pidfile, p.stderr + + +@test +def an_lv2_plugin_is_pinned_then_measured_at_the_highest_rate(): + code, doc, _, _, err = run_lv2() + check(doc is not None, "no document: exit %d %s" % (code, err)) + valid(doc) + check(code == 0 and doc["run"]["complete"] and doc["run"]["outcome"] == "probe.ok", "exit %d run %s" % (code, doc["run"])) + check(doc["plugin"]["format"] == "lv2" and doc["plugin"]["id"] == LV2_STRESS, "plugin %s" % doc["plugin"]) + files = {f["path"] for f in doc["binary"]["files"]} + check({"manifest.ttl", "plugin-hostd-fixture.so"} <= files, "the bundle's files are pinned: %s" % files) + check(doc["binary"]["path"] == "plugin-hostd-fixture.so", "binary is the library: %s" % doc["binary"]["path"]) + check([s["step"] for s in doc["steps"]][:1] == ["hash"] and "rt-safety" in [s["step"] for s in doc["steps"]], + "steps %s" % [s["step"] for s in doc["steps"]]) + check([(g["rate"], g["quantum"]) for g in doc["grid"]] == [(96000, 256)], "one point, the highest rate: %s" % doc["grid"]) + rt = doc["grid"][0]["rtSafety"] + check(rt["blocks"] > 0 and "allocations" in rt and "locks" in rt and "syscalls" in rt, "rtSafety %s" % rt) + check(doc["run"]["tools"].get("interposer") == "rt_interpose.so" and rt["interposer"], + "measured under the interposer beside the child: %s %s" % (doc["run"]["tools"], rt)) + b = doc["behaviour"] + check(b["lifecycle"]["cycles"] == b["lifecycle"]["instantiated"] > 0 and b["lifecycle"]["failed"] == 0, "lifecycle %s" % b) + check("threads" in b and "required" in b["features"], "threads and features %s" % b) + + +@test +def an_lv2_crash_keeps_the_pin_and_names_the_signal(): + code, doc, _, _, err = run_lv2("crash") + check(doc is not None, "no document after a crash: %s" % err) + valid(doc) + check(code == 1, "exit status %d, a crash is 1" % code) + steps = doc["steps"] + check(steps[0]["step"] == "hash" and steps[0]["outcome"] == "probe.ok", "the pin pass before the crash is kept: %s" % steps) + check(steps[-1]["outcome"] == "probe.crashed" and steps[-1]["signal"] == "SIGABRT", "the step it died in: %s" % steps[-1]) + check(doc["run"]["outcome"] == "probe.crashed" and not doc["run"]["complete"], "the run: %s" % doc["run"]) + check(doc["crash"]["signal"] == "SIGABRT", "the crash block names the signal") + + +@test +def an_lv2_plugin_that_hangs_ends_at_the_wall_clock(): + code, doc, took, _, _ = run_lv2("hang", wall=2, timeout=20) + check(code == 1, "exit status %d" % code) + valid(doc) + check(doc["run"]["outcome"] == "probe.timeout", "outcome %s" % doc["run"]["outcome"]) + check(took < 2 + 0.2 + 1.5, "ended in %.1f s, the deadline and the grace being 2.2 s" % took) + + +@test +def a_process_an_lv2_plugin_left_in_its_own_session_is_gone_after_the_run(): + code, doc, _, pidfile, _ = run_lv2("fork") + try: + check(os.path.exists(pidfile), "the stress plugin did not leave its process") + pid = int(open(pidfile).read()) + time.sleep(0.2) + check(not os.path.exists("/proc/%d" % pid), "pid %d, the plugin's own-session process, is still alive" % pid) + valid(doc) + check("left behind were killed" in " ".join(s.get("detail", "") for s in doc["steps"]), "the document says it killed a leftover") + finally: + kill_leftover(pidfile) + + +@test +def an_lv2_plugin_no_bundle_holds_is_3_and_has_no_document(): + code, doc, _, _, _ = run_lv2(uri="urn:plugin-hostd:test:nowhere") + check(code == 3 and doc is None, "exit %d, document %s" % (code, doc is not None)) + + +@test +def the_real_lv2_measure_is_translated_when_it_is_installed(): + if not os.access(REAL_LV2_MEASURE, os.X_OK): + print("skip the_real_lv2_measure_is_translated_when_it_is_installed: %s is not installed" % REAL_LV2_MEASURE) + return + code, doc, _, _, err = run_lv2(uri="urn:plugin-hostd:test:gain", child=REAL_LV2_MEASURE, wall=60, timeout=120) + check(doc is not None, "no document: exit %d %s" % (code, err)) + valid(doc) + check(doc["run"]["outcome"] == "probe.ok", "the gain fixture measures: %s %s" % (doc["run"], doc["steps"])) + check(doc["binary"]["layout"]["scheme"] == "omx-layout/1", "the real child's layout pin is kept: %s" % doc["binary"]) + check(doc["grid"][0]["rtSafety"]["blocks"] > 0, "rtSafety %s" % doc["grid"]) + if os.path.exists(os.path.join(os.path.dirname(REAL_LV2_MEASURE), "rt_interpose.so")): + check(doc["run"]["tools"].get("interposer") == "rt_interpose.so" and doc["grid"][0]["rtSafety"]["interposer"], + "the measurement ran under plugin-qualify's interposer: %s %s" % (doc["run"]["tools"], doc["grid"][0]["rtSafety"])) + + for t in tests: if ONLY and t.__name__ != ONLY: continue diff --git a/tests/sabotage.py b/tests/sabotage.py index 115c63b..585f802 100644 --- a/tests/sabotage.py +++ b/tests/sabotage.py @@ -348,6 +348,19 @@ "the_limits_reach_the_child", "probe"), ("the child is not pinned", "src/probe.c", " if (pin_option && sched_setaffinity(0, sizeof(*pinned), pinned) != 0)\n _exit(120);\n", "", "the_limits_reach_the_child", "probe"), + ("an LV2 plugin is pinned and never measured", "src/probe.c", " if (strcmp(*outcome, OUT_OK))\n return 0;\n\n snprintf(rate_s", + " return 0;\n\n snprintf(rate_s", "an_lv2_plugin_is_pinned_then_measured_at_the_highest_rate", "probe"), + ("an LV2 plugin no bundle holds gets a document", "src/probe.c", "WEXITSTATUS(r->status) == 3 &&\n !jbool(*pin, \"found\", 1))", + "WEXITSTATUS(r->status) == 3 &&\n 0)", "an_lv2_plugin_no_bundle_holds_is_3_and_has_no_document", "probe"), + ("the LV2 passes run past the wall clock", "src/probe.c", "&pinned, pin_option, t0 + o.wall_s * 1000, &r, &pin_line", + "&pinned, pin_option, t0 + 3600000, &r, &pin_line", "an_lv2_plugin_that_hangs_ends_at_the_wall_clock", "probe"), + ("the end of the LV2 measurement is not the run's", "src/probe.c", " *outcome = child_end(\"lifecycle\", o, r, complete);\n return 0;\n}", + " *complete = 1;\n return 0;\n}", "an_lv2_crash_keeps_the_pin_and_names_the_signal", "probe"), + ("the LV2 measurement runs without the interposer", "src/probe.c", " setenv(\"LD_PRELOAD\", g_interposer, 1);\n", " ;\n", + "an_lv2_plugin_is_pinned_then_measured_at_the_highest_rate", "probe"), + ("the LV2 measurement runs without the noise stimulus", "src/probe.c", + " setenv(PROBE_LV2_STIMULUS_ENV, PROBE_LV2_STIMULUS, 1);\n", "", + "an_lv2_plugin_is_pinned_then_measured_at_the_highest_rate", "probe"), ("the probe schema accepts a verdict", "protocol/probe-output.schema.json", ' "verdict"\n', ' "verdict-"\n', "success-msi validates", "probeschema"), ("the probe schema does not require binary.sha256", "protocol/probe-output.schema.json", From 014dd960c3b5b70fc68fe48af91ac15827b4e774 Mon Sep 17 00:00:00 2001 From: Pau Aliagas Date: Sun, 18 Oct 2026 12:18:38 +0200 Subject: [PATCH 3/3] plugin-hostd-probe: an LV2 plugin of which nothing is pinned is refused by its code With no pinned file the document would have no binary to name, so there is no document (exit 2), and the refusal now starts with its outcome code on stderr: ": probe.unpinnable: ", or the code of how the pin pass ended. The fake LV2 child gets an unpinnable variant of the stress plugin; the test fails before this change and has a sabotage arm. The README says that version 1 runs the quick measurement only. Refs #76 --- README.md | 4 +++- src/probe.c | 5 +++-- tests/lv2_probe_child.c | 12 +++++++++++- tests/probe_test.py | 7 +++++++ tests/sabotage.py | 4 ++++ 5 files changed, 28 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f3189ae..a4003ea 100644 --- a/README.md +++ b/README.md @@ -317,7 +317,9 @@ and 3 swept rt-safety runs. It runs under plugin-qualify's interposer (`rt_inter stimulus. The document's binary is the plugin's library, with every pinned file of the bundle listed. The rate's grid point carries what run() did under the interposer, and `behaviour` carries the lifecycles, the threads and the features the plugin requires. A plugin no bundle holds exits 3 with no document. A plugin of which nothing could be pinned exits 2 -with no document, because the document has no binary to name. +with no document, because the document has no binary to name. Its refusal is a code on stderr: `: : `, +`probe.unpinnable` for a bundle the pin cannot cover. Version 1 runs the quick measurement only: the deep lifecycle pass +and the soak stay plugin-qualify's until the probe protocol says which plugins get them and for how long. The runner kills the whole process tree of the child at its wall clock, and again when the child is done, so a process a plugin started in a session of its own does not outlive the run. It applies these limits on every Linux: the wall clock, diff --git a/src/probe.c b/src/probe.c index 91bf59e..8045a09 100644 --- a/src/probe.c +++ b/src/probe.c @@ -1716,8 +1716,9 @@ int main(int argc, char **argv) return 3; if (lv2_library == NULL) { - fprintf(stderr, RUNNER_NAME ": %s: no file of the plugin was pinned, so the document has no binary (%s)\n", - o.plugin, run_outcome); + /* the document needs a binary with its digest and none can be named: refused by its code, never a guess */ + fprintf(stderr, RUNNER_NAME ": %s: %s: %s; no document, it would have no binary\n", o.plugin, run_outcome, + g_nsteps && g_steps[g_nsteps - 1].detail[0] ? g_steps[g_nsteps - 1].detail : "nothing was pinned"); return 2; } } diff --git a/tests/lv2_probe_child.c b/tests/lv2_probe_child.c index f0ed372..280656f 100644 --- a/tests/lv2_probe_child.c +++ b/tests/lv2_probe_child.c @@ -122,12 +122,22 @@ static int pin_only(const char *uri) char *names[FILES_MAX]; int n = 0, i; - printf("{\"uri\":\"%s\",\"found\":%s", uri, find(uri) ? "true" : "false"); + char base[1024]; + const char *cut = strstr(uri, "-unpinnable"); + + /* "-unpinnable" is 's bundle, of which lv2_measure could pin nothing */ + snprintf(base, sizeof(base), "%.*s", cut ? (int)(cut - uri) : (int)strlen(uri), uri); + printf("{\"uri\":\"%s\",\"found\":%s", uri, find(base) ? "true" : "false"); if (!g_bundle[0]) { printf("}\n"); return 3; } + if (cut) + { + printf(",\"integrity\":{\"unmeasuredReason\":\"unpinnable\",\"unmeasuredDetail\":\"the manifest sets @base\"}}\n"); + return 0; + } d = opendir(g_bundle); while (d && (e = readdir(d)) && n < FILES_MAX) if (strstr(e->d_name, ".ttl") || !strcmp(e->d_name, g_library)) diff --git a/tests/probe_test.py b/tests/probe_test.py index a3a342b..ba27a6f 100644 --- a/tests/probe_test.py +++ b/tests/probe_test.py @@ -264,6 +264,13 @@ def an_lv2_plugin_no_bundle_holds_is_3_and_has_no_document(): check(code == 3 and doc is None, "exit %d, document %s" % (code, doc is not None)) +@test +def an_lv2_plugin_of_which_nothing_is_pinned_is_refused_by_code_with_no_document(): + code, doc, _, _, err = run_lv2(uri=LV2_STRESS + "-unpinnable") + check(code == 2 and doc is None, "exit %d, document %s" % (code, doc is not None)) + check("lv2:%s-unpinnable: probe.unpinnable: " % LV2_STRESS in err, "the refusal names its code: %r" % err) + + @test def the_real_lv2_measure_is_translated_when_it_is_installed(): if not os.access(REAL_LV2_MEASURE, os.X_OK): diff --git a/tests/sabotage.py b/tests/sabotage.py index 585f802..5f390e8 100644 --- a/tests/sabotage.py +++ b/tests/sabotage.py @@ -356,6 +356,10 @@ "&pinned, pin_option, t0 + 3600000, &r, &pin_line", "an_lv2_plugin_that_hangs_ends_at_the_wall_clock", "probe"), ("the end of the LV2 measurement is not the run's", "src/probe.c", " *outcome = child_end(\"lifecycle\", o, r, complete);\n return 0;\n}", " *complete = 1;\n return 0;\n}", "an_lv2_crash_keeps_the_pin_and_names_the_signal", "probe"), + ("an LV2 plugin of which nothing is pinned is refused with no code", "src/probe.c", + "RUNNER_NAME \": %s: %s: %s; no document, it would have no binary\\n\", o.plugin, run_outcome,", + "RUNNER_NAME \": %s: %s%s; no document, it would have no binary\\n\", o.plugin, \"\",", + "an_lv2_plugin_of_which_nothing_is_pinned_is_refused_by_code_with_no_document", "probe"), ("the LV2 measurement runs without the interposer", "src/probe.c", " setenv(\"LD_PRELOAD\", g_interposer, 1);\n", " ;\n", "an_lv2_plugin_is_pinned_then_measured_at_the_highest_rate", "probe"), ("the LV2 measurement runs without the noise stimulus", "src/probe.c",