diff --git a/Makefile b/Makefile index 9184a54..3b73daf 100644 --- a/Makefile +++ b/Makefile @@ -193,7 +193,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) tests/lv2-probe-child tests/rt_interpose.so $(LV2_FIXTURE) +sabotage: $(PROG) $(FAKE) tests/probe-child tests/stress.clap 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 diff --git a/README.md b/README.md index a8b5cb6..737d5da 100644 --- a/README.md +++ b/README.md @@ -324,11 +324,19 @@ Each rate's grid point carries the cost clap-qualify timed at that rate, in nano no input for the impulse, so its latency is measured from a note (`method: note-onset`), with `heard: false` at a rate where the note did not sound. +clap-qualify runs under plugin-qualify's interposer, `rt_interpose.so` beside the child. A bare `--child` is found as execvp +finds it; name the child by path when PATH is unset or holds a directory of its name. The measured child gets the interposer +and nothing else: an `LD_PRELOAD` the runner was started with is dropped for the measurement, named in `run.tools.droppedPreload` +and in the `rt-safety` step, and put back in the runner's environment after the child. The first grid point carries what process() did under it, and an instrument's +note sweep carries what process() did while notes played. `run.tools.interposer` names it once the child says it counted +under it. With no interposer beside the child, or one that did not load, no count is written: rtSafety says +`interposer: false`, and the `rt-safety` and `note-sweep` steps are `probe.not-attempted` and say why. + 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 +and 3 swept rt-safety runs. It runs under plugin-qualify's interposer (`rt_interpose.so` beside the child, found and with any +other preload dropped as for clap-qualify) 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, for a plugin with a MIDI input, its note sweep (what run() did while notes played), 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 diff --git a/doc/plugin-hostd-probe.1 b/doc/plugin-hostd-probe.1 index 8094bdb..e810da6 100644 --- a/doc/plugin-hostd-probe.1 +++ b/doc/plugin-hostd-probe.1 @@ -14,12 +14,20 @@ plugin\-hostd\-probe \- run a plugin prober under limits and write what happened .B plugin\-hostd\-probe 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 +the pin pass and then the measurement at the highest rate; both run under plugin\-qualify's interposer, rt_interpose.so beside +the child) 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 started and orphaned. .PP +A bare +.B \-\-child +is found as execvp finds it; name the child by path when PATH is unset or holds a directory of its name. +The measured child gets the interposer and nothing else: an LD_PRELOAD the runner was started with is dropped for the +measurement, named in run.tools.droppedPreload and in the rt\-safety step, and put back in the runner's environment after +the child. +.PP The document follows .IR plugin\-hostd/probe\-output.schema.json . Nothing a plugin prints can enter it. diff --git a/src/probe.c b/src/probe.c index 4a6d5bc..ab7e6ed 100644 --- a/src/probe.c +++ b/src/probe.c @@ -1345,13 +1345,108 @@ static const char *child_end(const char *name, const options_t *o, const run_t * * 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" +/* both measurements run as plugin-qualify runs them: under its allocation-and-syscall interposer, the file of this name + * beside the child (none beside it: rtSafety says interposer false, and the rt-safety step says why); an LV2 one also + * under the noise stimulus */ +#define PROBE_INTERPOSER "rt_interpose.so" #define PROBE_LV2_STIMULUS_ENV "OMX_STIM" #define PROBE_LV2_STIMULUS "noise" +/* the interposer preloaded into the measuring child, or empty; when empty, why none was */ static char g_interposer[PATH_MAX]; +static char g_no_interposer[PATH_MAX + 128]; + +/* the interposer beside `child`, the child found as execvp finds it (a bare name on PATH): 1 with g_interposer its path, + * else 0 with g_no_interposer saying where none was found */ +static int interposer_beside(const char *child) +{ + char dir[PATH_MAX]; + const char *slash = strrchr(child, '/'); + + g_interposer[0] = '\0'; + if (slash) + snprintf(dir, sizeof(dir), "%.*s", (int)(slash - child), child); + else + { + const char *path = getenv("PATH"), *p; + int found = 0; + + for (p = path ? path : ""; !found && *p;) + { + size_t n = strcspn(p, ":"); + char exe[PATH_MAX]; + + snprintf(dir, sizeof(dir), "%.*s", (int)(n ? n : 1), n ? p : "."); + snprintf(exe, sizeof(exe), "%s/%s", dir, child); + found = access(exe, X_OK) == 0; + p += n + (p[n] == ':'); + } + if (!found) + { + snprintf(g_no_interposer, sizeof(g_no_interposer), "%s is not on PATH, so no " PROBE_INTERPOSER " beside it was preloaded", child); + return 0; + } + } + snprintf(g_interposer, sizeof(g_interposer), "%s/" PROBE_INTERPOSER, dir); + if (access(g_interposer, R_OK) == 0) + return 1; + snprintf(g_no_interposer, sizeof(g_no_interposer), "no " PROBE_INTERPOSER " beside the child in %s", dir[0] ? dir : "/"); + g_interposer[0] = '\0'; + return 0; +} + +/* The measured child gets the interposer and nothing else: a preload the runner was started with (another malloc + * interposer would hide the allocations) is dropped for the measurement, the record says so, and the runner's own + * LD_PRELOAD is put back after the child */ +static int g_had_preload; +static char g_saved_preload[PATH_MAX * 4]; +static char g_dropped_preload[PATH_MAX * 4]; + +static void preload_for_measurement(const char *child) +{ + const char *was = getenv("LD_PRELOAD"); + + g_had_preload = was != NULL; + snprintf(g_saved_preload, sizeof(g_saved_preload), "%s", was ? was : ""); + g_dropped_preload[0] = '\0'; + if (interposer_beside(child)) + setenv("LD_PRELOAD", g_interposer, 1); + else + unsetenv("LD_PRELOAD"); + /* what the runner had and the child does not get is dropped */ + if (g_saved_preload[0] && strcmp(g_saved_preload, g_interposer)) + snprintf(g_dropped_preload, sizeof(g_dropped_preload), "%s", g_saved_preload); +} + +static void preload_restore(void) +{ + if (g_had_preload) + setenv("LD_PRELOAD", g_saved_preload, 1); + else + unsetenv("LD_PRELOAD"); +} + +/* an rt-safety detail, with the preload that was dropped for the measurement when there was one */ +static const char *with_dropped(const char *text) +{ + static char out[512]; + + if (!g_dropped_preload[0]) + return text; + snprintf(out, sizeof(out), "%s; dropped LD_PRELOAD=%s for the measurement", text, g_dropped_preload); + return out; +} + +/* why a child's line says no interposer counted: none was found beside it, or the one preloaded did not load */ +static const char *no_interposer_reason(void) +{ + static char text[PATH_MAX + 128]; + + if (!g_interposer[0]) + return g_no_interposer[0] ? g_no_interposer : "no interposer was preloaded"; + snprintf(text, sizeof(text), "%s was preloaded and the child says no interposer was loaded", g_interposer); + return text; +} static jnode_t *first_line(sb_t *out) { @@ -1423,25 +1518,11 @@ static int run_lv2(const options_t *o, const char *uri, long rate, const cpu_set 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); - } + preload_for_measurement(o->child); + 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"); + preload_restore(); *measure = first_line(&r->out); if (*measure && jbool(*measure, "instantiated", 0)) { @@ -1452,7 +1533,7 @@ static int run_lv2(const options_t *o, const char *uri, long rate, const cpu_set 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"); + set_detail(s, "%s", with_dropped(jbool(rt, "interposer", 0) ? "read from the child's line" : no_interposer_reason())); } else if (*measure && jbool(*measure, "found", 0)) { @@ -1473,7 +1554,7 @@ static void put_count(sb_t *b, const jnode_t *o, const char *from, const char *t } /* a prober's note sweep, as a point carries it: whether it applied, the dialect it played in, and what process() or run() - * did while it played when the interposer counted */ + * did while it played when the interposer counted; a sweep the interposer did not count carries no counts */ static void put_note_sweep(sb_t *b, const jnode_t *ns) { sb_printf(b, ", \"noteSweep\": {\"applicable\": %s", jbool(ns, "applicable", 0) ? "true" : "false"); @@ -1482,9 +1563,12 @@ static void put_note_sweep(sb_t *b, const jnode_t *ns) sb_printf(b, ", \"dialect\": "); sb_str(b, jstr(ns, "dialect")); } - put_count(b, ns, "allocationsInRun", "allocations"); - put_count(b, ns, "locksInRun", "locks"); - put_count(b, ns, "syscallsInRun", "syscalls"); + if (jbool(ns, "interposer", 0)) + { + put_count(b, ns, "allocationsInRun", "allocations"); + put_count(b, ns, "locksInRun", "locks"); + put_count(b, ns, "syscallsInRun", "syscalls"); + } sb_printf(b, "}"); } @@ -1820,7 +1904,10 @@ int main(int argc, char **argv) add_step("hash", OUT_OK, now_ms() - t0); clap_argv(&o, argv_child); + /* the CLAP prober counts under the interposer beside it, as the LV2 measurement does */ + preload_for_measurement(o.child); run_child(&o, argv_child, &pinned, pin_option, &r, t0 + o.wall_s * 1000); + preload_restore(); run_outcome = child_end("lifecycle", &o, &r, &complete); } read_idle(g_idle_after, &g_nidle, &pinned); @@ -1875,21 +1962,24 @@ int main(int argc, char **argv) { step_t *x = add_step("rt-safety", OUT_OK, 0); - set_detail(x, "counts are totals over every rate, read from the child's line"); + set_detail(x, "%s", with_dropped("counts are totals over every rate, read from the child's line")); } else if (rt) { step_t *x = add_step("rt-safety", OUT_NOT_ATTEMPTED, 0); - set_detail(x, "no interposer was loaded"); + set_detail(x, "%s", with_dropped(no_interposer_reason())); } { - step_t *x = add_step("note-sweep", jbool(notes, "applicable", 0) ? OUT_OK : OUT_NOT_ATTEMPTED, 0); + int counted = jbool(notes, "applicable", 0) && jbool(notes, "interposer", 0); + step_t *x = add_step("note-sweep", counted ? OUT_OK : OUT_NOT_ATTEMPTED, 0); if (!jbool(notes, "applicable", 0)) set_detail(x, "no note input"); - else + else if (counted) set_detail(x, "counts are totals over every rate, read from the child's line"); + else + set_detail(x, "the notes played uncounted: %s", no_interposer_reason()); } } for (i = 0; i < g_nsteps; i++) @@ -1911,11 +2001,17 @@ 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)); - if (g_interposer[0]) + /* the interposer is named when it was preloaded and the child says it counted under it */ + if (g_interposer[0] && jbool(lv2_uri ? jget(measure_line, "rtSafety") : rt, "interposer", 0)) { sb_printf(&doc, ", \"interposer\": "); sb_str(&doc, base_name(g_interposer)); } + if (g_dropped_preload[0]) + { + sb_printf(&doc, ", \"droppedPreload\": "); + sb_str(&doc, g_dropped_preload); + } sb_printf(&doc, "},\n \"via\": \"runner\"\n },\n \"plugin\": {\"format\": \"%s\", \"id\": ", lv2_uri ? "lv2" : "clap"); if (lv2_uri) { diff --git a/tests/probe_child.c b/tests/probe_child.c index ce10706..e593a10 100644 --- a/tests/probe_child.c +++ b/tests/probe_child.c @@ -28,7 +28,14 @@ * seen. PROBE_CHILD_INSTRUMENT makes the plugin an instrument: its latency is taken from a note, its cost holds the * notes --hold or --probe-notes give (no count: the rate is not timed) and it has a note sweep, counted under the * interposer when PROBE_CHILD_NOTE_ALLOCATIONS gives the allocations it saw. PROBE_CHILD_SILENT_RATE is a rate where - * the note was not heard, PROBE_CHILD_UNMEASURED_RATE one the cost arm did not time. */ + * the note was not heard, PROBE_CHILD_UNMEASURED_RATE one the cost arm did not time. + * + * As clap-qualify does, it says it ran under the interposer only when the interposer is in the process: the symbol of + * tests/rt_interpose_stub.c, which the runner preloads as the rt_interpose.so beside this program. Then its + * rtSafety carries counts, PROBE_CHILD_ALLOCATIONS allocations (0 when unset); without it rtSafety says interposer false + * and has none. Its note sweep says the same of the interposer but prints PROBE_CHILD_NOTE_ALLOCATIONS either way, so a + * runner that copies counts the interposer did not take is seen. PROBE_CHILD_NO_RECORDS writes no step records, as + * clap-qualify writes none, so the runner makes its steps from the line. */ #ifndef _GNU_SOURCE #define _GNU_SOURCE @@ -104,7 +111,11 @@ static bool no_events_push(const clap_output_events_t *list, const clap_event_he static void record(const char *step, long rate, long quantum, long since) { char line[256]; - int n = snprintf(line, sizeof(line), "{\"step\":\"%s\",\"outcome\":\"probe.ok\",\"elapsedMs\":%ld", step, now_ms() - since); + int n; + + if (getenv("PROBE_CHILD_NO_RECORDS")) + return; + n = snprintf(line, sizeof(line), "{\"step\":\"%s\",\"outcome\":\"probe.ok\",\"elapsedMs\":%ld", step, now_ms() - since); if (rate) n += snprintf(line + n, sizeof(line) - (size_t)n, ",\"rate\":%ld,\"quantum\":%ld", rate, quantum); @@ -117,6 +128,8 @@ static void record(const char *step, long rate, long quantum, long since) static void put_rates(const char *rates, long quantum, unsigned notes) { const char *instrument = getenv("PROBE_CHILD_INSTRUMENT"), *note_allocs = getenv("PROBE_CHILD_NOTE_ALLOCATIONS"); + const char *allocs = getenv("PROBE_CHILD_ALLOCATIONS"); + int interposed = dlsym(RTLD_DEFAULT, "plugin_hostd_rt_interpose_stub") != NULL; long silent = getenv("PROBE_CHILD_SILENT_RATE") ? atol(getenv("PROBE_CHILD_SILENT_RATE")) : 0; long unmeasured = getenv("PROBE_CHILD_UNMEASURED_RATE") ? atol(getenv("PROBE_CHILD_UNMEASURED_RATE")) : 0; const char *p; @@ -127,12 +140,18 @@ static void put_rates(const char *rates, long quantum, unsigned notes) for (p = rates; (r = strtol(p, &end, 10)) > 0; p = *end ? end + 1 : end) printf("%s\"%ld\":{\"declaredFrames\":0,\"measuredFrames\":%d%s%s}", p == rates ? "" : ",", r, instrument ? 1 : 0, instrument ? ",\"from\":\"note\"" : "", instrument && r == silent ? ",\"heard\":false" : ""); - printf("},\"threadViolations\":0,\"processErrors\":0},\"rtSafety\":{\"interposer\":false}"); + printf("},\"threadViolations\":0,\"processErrors\":0}"); + if (interposed) + printf(",\"rtSafety\":{\"interposer\":true,\"swept\":false,\"sweepApplicable\":true,\"blocks\":64,\"allocationsInRun\":%ld," + "\"locksInRun\":0,\"syscallsInRun\":0,\"events\":[]}", allocs ? atol(allocs) : 0L); + else + printf(",\"rtSafety\":{\"interposer\":false}"); if (instrument && note_allocs) - printf(",\"noteSweep\":{\"applicable\":true,\"dialect\":\"clap\",\"interposer\":true,\"allocationsInRun\":%ld," - "\"locksInRun\":0,\"syscallsInRun\":0,\"events\":[]}", atol(note_allocs)); + printf(",\"noteSweep\":{\"applicable\":true,\"dialect\":\"clap\",\"interposer\":%s,\"allocationsInRun\":%ld," + "\"locksInRun\":0,\"syscallsInRun\":0,\"events\":[]}", interposed ? "true" : "false", atol(note_allocs)); else - printf(",\"noteSweep\":{\"applicable\":%s%s}", instrument ? "true" : "false", instrument ? ",\"dialect\":\"clap\",\"interposer\":false" : ""); + printf(",\"noteSweep\":{\"applicable\":%s%s%s}", instrument ? "true" : "false", instrument ? ",\"dialect\":\"clap\",\"interposer\":" : "", + instrument ? (interposed ? "true" : "false") : ""); printf(",\"cost\":{\"stimulus\":\"%s\",\"blockFrames\":%ld,\"warmupBlocks\":64,\"percentile\":95,\"perRate\":{", instrument ? "notes-held" : "noise", quantum); for (p = rates; (r = strtol(p, &end, 10)) > 0; p = *end ? end + 1 : end) @@ -198,6 +217,18 @@ int main(int argc, char **argv) if (f) fclose(f); } + /* PROBE_CHILD_ENV names a file to write the environment the runner gave, one variable to a line */ + if (getenv("PROBE_CHILD_ENV")) + { + extern char **environ; + FILE *f = fopen(getenv("PROBE_CHILD_ENV"), "w"); + char **e; + + for (e = environ; f && *e; e++) + fprintf(f, "%s\n", *e); + if (f) + fclose(f); + } rate = atol(rates); lib = dlopen(path, RTLD_NOW | RTLD_LOCAL); entry = lib ? dlsym(lib, "clap_entry") : NULL; diff --git a/tests/probe_test.py b/tests/probe_test.py index b9ac48d..bed0a38 100644 --- a/tests/probe_test.py +++ b/tests/probe_test.py @@ -33,7 +33,7 @@ def check(cond, what): raise AssertionError(what) -def run(fault=None, wall=5, extra=(), timeout=30, env=None, rates="48000"): +def run(fault=None, wall=5, extra=(), timeout=30, env=None, rates="48000", child=CHILD): """the runner over the stress plugin; returns (exit status, document or None, seconds, the files it used); rates None leaves the runner's default rates""" work = tempfile.mkdtemp(prefix="plugin-hostd-probe-test.") @@ -44,7 +44,7 @@ def run(fault=None, wall=5, extra=(), timeout=30, env=None, rates="48000"): e["STRESS_FAULT"] = fault else: e.pop("STRESS_FAULT", None) - cmd = [PROBE, "run", "--child", CHILD, *(("--rates", rates) if rates else ()), "--quantum", "256", "--wall-s", str(wall), + cmd = [PROBE, "run", "--child", child, *(("--rates", rates) if rates else ()), "--quantum", "256", "--wall-s", str(wall), "--grace-ms", "200", "--log", os.path.join(work, "child.log"), "--out", out, *extra, CLAP, ID] start = time.monotonic() try: @@ -322,6 +322,115 @@ def a_clap_point_carries_the_note_sweep_counts(): ns = doc["grid"][0].get("noteSweep") check(ns == {"applicable": True, "dialect": "clap", "allocations": 3, "locks": 0, "syscalls": 0}, "noteSweep %s" % ns) +def child_alone(interposer=None): + """a copy of the fake CLAP child in a directory of its own, with no interposer beside it, or with `interposer` + (the bytes of a file named rt_interpose.so) beside it""" + work = tempfile.mkdtemp(prefix="plugin-hostd-probe-alone.") + child = os.path.join(work, "probe-child") + with open(CHILD, "rb") as src, open(child, "wb") as dst: + dst.write(src.read()) + os.chmod(child, 0o755) + if interposer is not None: + open(os.path.join(work, "rt_interpose.so"), "wb").write(interposer) + return work, child + + +def step(doc, name): + return next((s for s in doc["steps"] if s["step"] == name), None) + + +@test +def a_clap_child_runs_under_the_interposer_beside_it(): + code, doc, _, _, err = run(rates=None, extra=("--probe-notes", "4"), + env={"PROBE_CHILD_ALLOCATIONS": "5", "PROBE_CHILD_INSTRUMENT": "1", "PROBE_CHILD_NOTE_ALLOCATIONS": "3", + "PROBE_CHILD_NO_RECORDS": "1"}) + check(doc is not None, "no document: exit %d %s" % (code, err)) + valid(doc) + check(doc["run"]["tools"].get("interposer") == "rt_interpose.so", "the run names the interposer: %s" % doc["run"]["tools"]) + rt = doc["grid"][0].get("rtSafety", {}) + check(rt.get("interposer") is True and rt.get("allocations") == 5 and rt.get("locks") == 0 and rt.get("syscalls") == 0, + "the first point carries the counts taken under the interposer: %s" % rt) + ns = doc["grid"][0].get("noteSweep") + check(ns == {"applicable": True, "dialect": "clap", "allocations": 3, "locks": 0, "syscalls": 0}, "noteSweep %s" % ns) + for name in ("rt-safety", "note-sweep"): + s = step(doc, name) + check(s is not None and s["outcome"] == "probe.ok", "the %s step: %s" % (name, s)) + + +@test +def a_bare_clap_child_is_found_on_path_and_runs_under_the_interposer_beside_it(): + code, doc, _, _, err = run(child=os.path.basename(CHILD), env={"PATH": os.path.dirname(CHILD) + ":" + os.environ.get("PATH", ""), + "PROBE_CHILD_ALLOCATIONS": "2"}) + check(doc is not None, "no document: exit %d %s" % (code, err)) + check(doc["run"]["tools"].get("interposer") == "rt_interpose.so" and doc["grid"][0]["rtSafety"].get("allocations") == 2, + "the child named by PATH counts under the interposer beside it: %s %s" % (doc["run"]["tools"], doc["grid"][0])) + + +@test +def a_clap_child_with_no_interposer_beside_it_is_unmeasured_and_says_why(): + work, child = child_alone() + code, doc, _, _, err = run(child=child, extra=("--probe-notes", "4"), + env={"PROBE_CHILD_INSTRUMENT": "1", "PROBE_CHILD_NOTE_ALLOCATIONS": "3", "PROBE_CHILD_NO_RECORDS": "1"}) + check(doc is not None, "no document: exit %d %s" % (code, err)) + valid(doc) + check("interposer" not in doc["run"]["tools"], "no interposer is named: %s" % doc["run"]["tools"]) + check(doc["grid"][0].get("rtSafety") == {"interposer": False}, "no counts without the interposer: %s" % doc["grid"][0]) + check(doc["grid"][0].get("noteSweep") == {"applicable": True, "dialect": "clap"}, + "a note sweep the interposer did not count carries no counts, whatever the child printed: %s" % doc["grid"][0].get("noteSweep")) + rt, ns = step(doc, "rt-safety"), step(doc, "note-sweep") + why = "no rt_interpose.so beside the child in %s" % work + check(rt is not None and rt["outcome"] == "probe.not-attempted" and rt.get("detail") == why, "the rt-safety step says why: %s" % rt) + check(ns is not None and ns["outcome"] == "probe.not-attempted" and why in ns.get("detail", ""), "the note-sweep step says why: %s" % ns) + + +def child_env(path): + return dict(l.split("=", 1) for l in open(path).read().split("\n") if "=" in l) + + +@test +def a_preload_the_runner_had_is_dropped_for_the_measurement_and_recorded(): + work = tempfile.mkdtemp(prefix="plugin-hostd-probe-env.") + seen = os.path.join(work, "env") + stub = os.path.join(os.path.dirname(CHILD), "rt_interpose.so") + # a library the runner itself was started under, which is no interposer of the measurement + foreign = CLAP + code, doc, _, _, err = run(env={"LD_PRELOAD": foreign, "PROBE_CHILD_ENV": seen, "PROBE_CHILD_NO_RECORDS": "1"}) + check(doc is not None, "no document: exit %d %s" % (code, err)) + valid(doc) + check(child_env(seen).get("LD_PRELOAD") == stub, "the measured child gets only the interposer: %s" % child_env(seen).get("LD_PRELOAD")) + check(doc["run"]["tools"].get("droppedPreload") == foreign, "the run records the preload it dropped: %s" % doc["run"]["tools"]) + rt = step(doc, "rt-safety") + check(rt is not None and rt["outcome"] == "probe.ok" and ("dropped LD_PRELOAD=%s for the measurement" % foreign) in rt.get("detail", ""), + "the rt-safety step says what was dropped: %s" % rt) + # with no interposer beside the child, the foreign preload is dropped all the same + _, alone = child_alone() + code, doc, _, _, err = run(child=alone, env={"LD_PRELOAD": foreign, "PROBE_CHILD_ENV": seen, "PROBE_CHILD_NO_RECORDS": "1"}) + check("LD_PRELOAD" not in child_env(seen), "a child with no interposer beside it gets no preload: %s" % child_env(seen).get("LD_PRELOAD")) + check(doc["run"]["tools"].get("droppedPreload") == foreign and "dropped LD_PRELOAD" in step(doc, "rt-safety").get("detail", ""), + "and the record says so: %s %s" % (doc["run"]["tools"], step(doc, "rt-safety"))) + # the interposer itself, already preloaded (as plugin-qualify starts the runner), is not dropped + code, doc, _, _, err = run(env={"LD_PRELOAD": stub, "PROBE_CHILD_ENV": seen, "PROBE_CHILD_NO_RECORDS": "1"}) + check(child_env(seen).get("LD_PRELOAD") == stub and "droppedPreload" not in doc["run"]["tools"], + "the interposer the child gets is not called dropped: %s" % doc["run"]["tools"]) + code, doc, _, _, err = run_lv2(env={"LD_PRELOAD": foreign}) + valid(doc) + check(doc["run"]["tools"].get("droppedPreload") == foreign and "dropped LD_PRELOAD" in step(doc, "rt-safety").get("detail", ""), + "an LV2 measurement records it too: %s %s" % (doc["run"]["tools"], step(doc, "rt-safety"))) + + +@test +def an_interposer_that_does_not_load_is_not_named_and_the_record_says_so(): + work, child = child_alone(interposer=b"not a library\n") + code, doc, _, _, err = run(child=child, env={"PROBE_CHILD_NO_RECORDS": "1"}) + check(doc is not None, "no document: exit %d %s" % (code, err)) + valid(doc) + check("interposer" not in doc["run"]["tools"], "an interposer that did not load is not named: %s" % doc["run"]["tools"]) + check(doc["grid"][0].get("rtSafety") == {"interposer": False}, "no counts: %s" % doc["grid"][0]) + rt = step(doc, "rt-safety") + want = "%s/rt_interpose.so was preloaded and the child says no interposer was loaded" % work + check(rt is not None and rt["outcome"] == "probe.not-attempted" and rt.get("detail") == want, "the rt-safety step: %s" % rt) + + @test def an_lv2_crash_keeps_the_pin_and_names_the_signal(): code, doc, _, _, err = run_lv2("crash") @@ -435,6 +544,33 @@ def the_real_prober_times_an_instrument_holding_the_probe_notes_at_every_floor_r "an instrument declaring its voices holds that many at %d: %s" % (g["rate"], v)) +@test +def the_real_prober_counts_under_the_interposer_beside_it_at_every_floor_rate(): + build = pq("the_real_prober_counts_under_the_interposer_beside_it_at_every_floor_rate") + if not build: + return + rates = floor_rates() + seen = {} + for plugin, extra in (("rt-safe.clap", ()), ("rt-unsafe.clap", ()), ("synth.clap", ("--probe-notes", "32")), + ("synth-alloc.clap", ("--probe-notes", "32"))): + code, doc, err = run_pq_clap(build, plugin, extra) + check(doc is not None, "%s: no document: exit %d %s" % (plugin, code, err)) + valid(doc) + check([g["rate"] for g in doc["grid"]] == rates, "%s: a point per floor rate %s: %s" % (plugin, rates, [g["rate"] for g in doc["grid"]])) + check(doc["run"]["tools"].get("interposer") == "rt_interpose.so", "%s: the run names the interposer: %s" % (plugin, doc["run"]["tools"])) + rt = doc["grid"][0].get("rtSafety", {}) + check(rt.get("interposer") is True and rt.get("blocks", 0) > 0 and "allocations" in rt, "%s: rtSafety %s" % (plugin, rt)) + check(step(doc, "rt-safety")["outcome"] == "probe.ok", "%s: the rt-safety step %s" % (plugin, step(doc, "rt-safety"))) + seen[plugin] = (rt, doc["grid"][0].get("noteSweep", {})) + check(seen["rt-safe.clap"][0]["allocations"] == 0, "the clean effect allocates nothing: %s" % (seen["rt-safe.clap"],)) + check(seen["rt-unsafe.clap"][0]["allocations"] > 0, "the allocating effect is counted: %s" % (seen["rt-unsafe.clap"],)) + for plugin in ("synth.clap", "synth-alloc.clap"): + ns = seen[plugin][1] + check(ns.get("applicable") is True and ns.get("dialect") == "clap" and "allocations" in ns, "%s noteSweep %s" % (plugin, ns)) + check(seen["synth.clap"][1]["allocations"] == 0, "the clean synth allocates nothing under the notes: %s" % (seen["synth.clap"],)) + check(seen["synth-alloc.clap"][1]["allocations"] > 0, "the allocating synth is counted under the notes: %s" % (seen["synth-alloc.clap"],)) + + @test def the_real_lv2_measure_counts_what_run_does_while_notes_play(): build = pq("the_real_lv2_measure_counts_what_run_does_while_notes_play") diff --git a/tests/rt_interpose_stub.c b/tests/rt_interpose_stub.c index 2ddf3a6..4c564ec 100644 --- a/tests/rt_interpose_stub.c +++ b/tests/rt_interpose_stub.c @@ -1,4 +1,6 @@ /* 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; +/* An empty library under the name of plugin-qualify's interposer, rt_interpose.so, built beside tests/lv2-probe-child and + * tests/probe-child: the runner preloads the file of that name beside its LV2 or CLAP child, and the fake children report + * whether it was. The CLAP child looks for this symbol, as clap-qualify looks for the interposer's own, so a file of + * the name that did not load is seen not to have. */ +__attribute__((visibility("default"))) int plugin_hostd_rt_interpose_stub; diff --git a/tests/sabotage.py b/tests/sabotage.py index c1943a8..3741ad5 100644 --- a/tests/sabotage.py +++ b/tests/sabotage.py @@ -442,10 +442,40 @@ "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", + ("the LV2 measurement runs without the interposer", "src/probe.c", + " preload_for_measurement(o->child);\n", " interposer_beside(o->child);\n", "an_lv2_plugin_is_pinned_then_measured_at_the_highest_rate", "probe"), + ("the CLAP prober runs without the interposer", "src/probe.c", + " preload_for_measurement(o.child);\n", " interposer_beside(o.child);\n", + "a_clap_child_runs_under_the_interposer_beside_it", "probe"), + ("the measured child keeps the preload the runner had", "src/probe.c", + ' setenv("LD_PRELOAD", g_interposer, 1);\n else\n unsetenv("LD_PRELOAD");\n', + ' setenv("LD_PRELOAD", g_saved_preload[0] ? g_saved_preload : g_interposer, 1);\n else\n unsetenv("LD_PRELOAD");\n', + "a_preload_the_runner_had_is_dropped_for_the_measurement_and_recorded", "probe"), + ("a dropped preload is not recorded", "src/probe.c", + ' snprintf(g_dropped_preload, sizeof(g_dropped_preload), "%s", g_saved_preload);\n', " ;\n", + "a_preload_the_runner_had_is_dropped_for_the_measurement_and_recorded", "probe"), + ("a dropped preload is left out of the run's tools", "src/probe.c", + ' if (g_dropped_preload[0])\n {\n sb_printf(&doc, ", \\"droppedPreload\\": ");', ' if (0)\n {\n sb_printf(&doc, ", \\"droppedPreload\\": ");', + "a_preload_the_runner_had_is_dropped_for_the_measurement_and_recorded", "probe"), + ("the interposer the child gets is called dropped", "src/probe.c", + " if (g_saved_preload[0] && strcmp(g_saved_preload, g_interposer))\n", " if (g_saved_preload[0])\n", + "a_preload_the_runner_had_is_dropped_for_the_measurement_and_recorded", "probe"), + ("a bare child is not looked for on PATH", "src/probe.c", " found = access(exe, X_OK) == 0;\n", " found = 0;\n", + "a_bare_clap_child_is_found_on_path_and_runs_under_the_interposer_beside_it", "probe"), + ("a CLAP point drops the allocations the interposer counted", "src/probe.c", + ' if (jnum(rt, "allocationsInRun", &v))\n sb_printf(&doc, ", \\"allocations\\": %.0f", v);\n', "", + "a_clap_child_runs_under_the_interposer_beside_it", "probe"), + ("a note sweep the interposer did not count keeps the child's counts", "src/probe.c", + ' if (jbool(ns, "interposer", 0))\n {\n put_count', " if (1)\n {\n put_count", + "a_clap_child_with_no_interposer_beside_it_is_unmeasured_and_says_why", "probe"), + ("an unmeasured CLAP rt-safety does not say why", "src/probe.c", ' set_detail(x, "%s", with_dropped(no_interposer_reason()));\n', + ' set_detail(x, "no interposer was loaded");\n', "a_clap_child_with_no_interposer_beside_it_is_unmeasured_and_says_why", "probe"), + ("an interposer that did not load is named", "src/probe.c", + ' if (g_interposer[0] && jbool(lv2_uri ? jget(measure_line, "rtSafety") : rt, "interposer", 0))', " if (g_interposer[0])", + "an_interposer_that_does_not_load_is_not_named_and_the_record_says_so", "probe"), ("the LV2 measurement runs without the noise stimulus", "src/probe.c", - " setenv(PROBE_LV2_STIMULUS_ENV, PROBE_LV2_STIMULUS, 1);\n", "", + " setenv(PROBE_LV2_STIMULUS_ENV, PROBE_LV2_STIMULUS, 1);\n", "", "an_lv2_plugin_is_pinned_then_measured_at_the_highest_rate", "probe"), ("--probe-notes does not reach the CLAP prober", "src/probe.c", " if (o->probe_notes)\n {", " if (0)\n {", "the_notes_an_instrument_holds_reach_the_clap_child", "probe"),