@openmixer/plugin-qualify 0.18.4 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +19 -730
  2. package/bin/plugin-qualify-sync.mjs +4 -2
  3. package/bin/plugin-qualify.mjs +7 -0
  4. package/dist/clap-qualify.d.ts +1 -1
  5. package/dist/compare.d.ts +311 -0
  6. package/dist/compare.js +502 -0
  7. package/dist/compare.js.map +1 -0
  8. package/dist/distributed-records.d.ts +0 -2
  9. package/dist/distributed-records.js +0 -2
  10. package/dist/distributed-records.js.map +1 -1
  11. package/dist/host-admission.d.ts +2 -2
  12. package/dist/host-admission.js +7 -0
  13. package/dist/host-admission.js.map +1 -1
  14. package/dist/hosting-codes.generated.d.ts +1 -0
  15. package/dist/hosting-codes.generated.js +1 -0
  16. package/dist/hosting-codes.generated.js.map +1 -1
  17. package/dist/hosting-suitability.d.ts +3 -1
  18. package/dist/hosting-suitability.js +9 -0
  19. package/dist/hosting-suitability.js.map +1 -1
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.js +1 -0
  22. package/dist/index.js.map +1 -1
  23. package/dist/jack-registry.d.ts +6 -0
  24. package/dist/jack-registry.js +52 -0
  25. package/dist/jack-registry.js.map +1 -0
  26. package/dist/lv2-loader.js +6 -5
  27. package/dist/lv2-loader.js.map +1 -1
  28. package/dist/measurement/format.d.ts +15 -0
  29. package/dist/measurement/format.js +19 -0
  30. package/dist/measurement/format.js.map +1 -1
  31. package/dist/measurement/sweep-jsonl.js +5 -1
  32. package/dist/measurement/sweep-jsonl.js.map +1 -1
  33. package/dist/measurement-grid.js +1 -1
  34. package/dist/pins-file.d.ts +22 -2
  35. package/dist/pins-file.js +118 -17
  36. package/dist/pins-file.js.map +1 -1
  37. package/dist/probe-judge.d.ts +5 -0
  38. package/dist/probe-judge.js.map +1 -1
  39. package/dist/probe-runner.d.ts +8 -0
  40. package/dist/probe-runner.js +8 -4
  41. package/dist/probe-runner.js.map +1 -1
  42. package/dist/qualification-record.d.ts +45 -0
  43. package/dist/qualification-record.js +36 -0
  44. package/dist/qualification-record.js.map +1 -1
  45. package/dist/qualify-adapter-pass.js +20 -5
  46. package/dist/qualify-adapter-pass.js.map +1 -1
  47. package/dist/qualify-declarations.d.ts +6 -0
  48. package/dist/qualify-declarations.js +8 -0
  49. package/dist/qualify-declarations.js.map +1 -1
  50. package/dist/qualify-isolated.d.ts +16 -3
  51. package/dist/qualify-isolated.js +31 -3
  52. package/dist/qualify-isolated.js.map +1 -1
  53. package/dist/qualify-pool.d.ts +129 -0
  54. package/dist/qualify-pool.js +290 -0
  55. package/dist/qualify-pool.js.map +1 -0
  56. package/dist/qualify-rejudge.js +7 -0
  57. package/dist/qualify-rejudge.js.map +1 -1
  58. package/dist/qualify-sync.d.ts +46 -1
  59. package/dist/qualify-sync.js +184 -20
  60. package/dist/qualify-sync.js.map +1 -1
  61. package/dist/records-package.d.ts +5 -0
  62. package/dist/records-package.js +25 -8
  63. package/dist/records-package.js.map +1 -1
  64. package/fixtures/clap-scan/fake.json +8 -2
  65. package/fixtures/probe-output/success-msi.json +2 -2
  66. package/package.json +3 -3
  67. package/src/clap-qualify.ts +1 -1
  68. package/src/compare.ts +699 -0
  69. package/src/distributed-records.ts +0 -3
  70. package/src/host-admission.ts +9 -2
  71. package/src/hosting-codes.generated.ts +1 -0
  72. package/src/hosting-suitability.ts +14 -0
  73. package/src/index.ts +1 -0
  74. package/src/jack-registry.ts +49 -0
  75. package/src/lv2-loader.ts +5 -4
  76. package/src/measurement/format.ts +32 -0
  77. package/src/measurement/sweep-jsonl.ts +5 -1
  78. package/src/measurement-grid.ts +1 -1
  79. package/src/pins-file.ts +109 -16
  80. package/src/probe-judge.ts +2 -0
  81. package/src/probe-runner.ts +18 -4
  82. package/src/qualification-record.ts +69 -0
  83. package/src/qualify-adapter-pass.ts +18 -5
  84. package/src/qualify-declarations.ts +8 -0
  85. package/src/qualify-isolated.ts +31 -5
  86. package/src/qualify-pool.ts +318 -0
  87. package/src/qualify-rejudge.ts +8 -0
  88. package/src/qualify-sync.ts +187 -19
  89. package/src/records-package.ts +25 -8
  90. package/tools/benchmark.py +9 -3
  91. package/tools/compare-host.mjs +432 -0
  92. package/tools/compare-run.mjs +355 -0
  93. package/tools/descriptors.py +27 -0
  94. package/tools/isolated-step.mjs +3 -1
  95. package/tools/lilv_load.py +25 -0
  96. package/tools/lv2_resolve.py +3 -1
  97. package/tools/pool-step.mjs +186 -0
  98. package/tools/scan.py +16 -20
  99. package/tools/audit-overlap.sh +0 -250
  100. package/tools/commit-message-check.sh +0 -128
  101. package/tools/commit-message-patterns.json +0 -41
  102. package/tools/private-refs-check.sh +0 -90
@@ -0,0 +1,355 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ // `plugin-qualify compare` and `plugin-qualify compare summary`: the processes and the files. Every decision is in
4
+ // src/compare.ts; this module finds the hosts and their versions, starts the private JACK server, runs
5
+ // tools/compare-host.mjs for each plugin and host, and writes the records and the summary.
6
+ import { spawn, spawnSync } from "node:child_process";
7
+ import { closeSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, rmSync, statfsSync } from "node:fs";
8
+ import { cpus, tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+
12
+ import {
13
+ COMPARISON_RECORD_SUFFIX,
14
+ COMPARISON_SUMMARY_FILE,
15
+ HOST_TRAITS,
16
+ comparisonFileName,
17
+ comparisonRecordOf,
18
+ comparisonRecordProblems,
19
+ compareHelpText,
20
+ discoverAllTargets,
21
+ clapLoader,
22
+ hostRowOf,
23
+ lv2Loader,
24
+ missingReferences,
25
+ notApplicableRow,
26
+ parseCompareArgs,
27
+ parseSummaryArgs,
28
+ presenceFromRecords,
29
+ resolveTargets,
30
+ sameConditions,
31
+ summarise,
32
+ summaryHelpText,
33
+ summaryTable,
34
+ writeFileWhole,
35
+ } from "../dist/index.js";
36
+
37
+ const DRIVER = fileURLToPath(new URL("./compare-host.mjs", import.meta.url));
38
+ const log = (line) => process.stderr.write(`plugin-qualify compare: ${line}\n`);
39
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
40
+ const sh = (cmd, args) => spawnSync(cmd, args, { encoding: "utf8", timeout: 10000 });
41
+
42
+ /** The installed package that owns a program, as "name version", from rpm or dpkg; else the program's own --version. */
43
+ function versionOf(program, path) {
44
+ // a host built outside a package (Zynthian's mod-host in /usr/local/bin) is named by the one who runs the comparison
45
+ const named = process.env[`OMX_COMPARE_VERSION_${program.toUpperCase().replace(/[^A-Z0-9]/g, "_")}`];
46
+ if (named) return `${program} ${named}`;
47
+ const rpm = sh("rpm", ["-qf", "--qf", "%{NAME} %{VERSION}-%{RELEASE}", path]);
48
+ if (rpm.status === 0 && rpm.stdout.trim()) return rpm.stdout.trim();
49
+ const dpkg = sh("dpkg-query", ["-S", path]);
50
+ if (dpkg.status === 0) {
51
+ const pkg = dpkg.stdout.split(":")[0].trim();
52
+ const v = sh("dpkg-query", ["-W", "-f", "${Version}", pkg]);
53
+ if (v.status === 0 && v.stdout.trim()) return `${pkg} ${v.stdout.trim()}`;
54
+ }
55
+ const own = sh(program, ["-V"]);
56
+ const line = `${own.stdout ?? ""}${own.stderr ?? ""}`.split("\n").find((l) => /\d+\.\d+/.test(l));
57
+ return line ? `${program} ${line.match(/\d+(\.\d+)+\S*/)[0]} (self-reported)` : null;
58
+ }
59
+
60
+ function presence(hosts) {
61
+ return hosts.map((host) => {
62
+ const program = HOST_TRAITS[host].program;
63
+ const w = sh("sh", ["-c", `command -v "$1"`, "sh", program]);
64
+ const path = w.status === 0 ? w.stdout.trim() : "";
65
+ if (!path) return { host, installed: false, version: null, reason: `${program} is not installed (not on PATH)` };
66
+ return { host, installed: true, version: versionOf(program, path) };
67
+ });
68
+ }
69
+
70
+ /** The private JACK server: dummy driver, the run's rate and quantum, a name of its own. */
71
+ let jackSerial = 0;
72
+ async function startJack(rate, quantum) {
73
+ const name = `cmp${String(process.pid)}_${String(jackSerial++)}`;
74
+ const env = { ...process.env, JACK_NO_START_SERVER: "1" };
75
+ // the server's output goes to a file, never to a pipe: this process waits on each host's run, a pipe it does not
76
+ // read fills, and a JACK server blocked writing its log stops answering
77
+ const logFile = join(tmpdir(), `plugin-qualify-${name}.log`);
78
+ const fd = openSync(logFile, "w");
79
+ const p = spawn("jackd", ["-n", name, "-d", "dummy", "-r", String(rate), "-p", String(quantum)], { stdio: ["ignore", fd, fd], detached: true, env });
80
+ closeSync(fd);
81
+ const lastLine = () => {
82
+ try {
83
+ return readFileSync(logFile, "utf8").trim().split("\n").filter((l) => !/lock down/.test(l)).pop() ?? "";
84
+ } catch {
85
+ return "";
86
+ }
87
+ };
88
+ let failed;
89
+ p.on("error", (e) => (failed = e.code));
90
+ p.on("exit", (c, s) => (failed = failed ?? s ?? `exit ${String(c)}`));
91
+ const probe = locateCompareTool();
92
+ for (let i = 0; i < 100 && failed === undefined; i++) {
93
+ const r = spawnSync(probe, ["ports"], { env: { ...env, JACK_DEFAULT_SERVER: name }, stdio: "ignore", timeout: 5000 });
94
+ if (r.status === 0) {
95
+ return {
96
+ name,
97
+ proc: p,
98
+ alive: () => failed === undefined && answers(name),
99
+ // SIGTERM first: a JACK server ended so removes its shared memory (100 MB at the defaults), which a killed one
100
+ // leaves in /dev/shm for the next server to run short of
101
+ stop: async () => {
102
+ try {
103
+ process.kill(-p.pid, "SIGTERM");
104
+ } catch {
105
+ return;
106
+ }
107
+ for (let i = 0; i < 50 && failed === undefined; i++) await sleep(100);
108
+ try {
109
+ process.kill(-p.pid, "SIGKILL");
110
+ } catch {}
111
+ rmSync(logFile, { force: true });
112
+ },
113
+ };
114
+ }
115
+ await sleep(100);
116
+ }
117
+ try {
118
+ process.kill(-p.pid, "SIGKILL");
119
+ } catch {}
120
+ throw new Error(`the private JACK server did not start (${failed ?? "no answer"}): ${lastLine()}`);
121
+ }
122
+
123
+ /** The server lists its ports within five seconds: a server that does not is not one to measure on. */
124
+ function answers(name) {
125
+ const t0 = Date.now();
126
+ const r = spawnSync(locateCompareTool(), ["ports"], { env: { ...process.env, JACK_DEFAULT_SERVER: name, JACK_NO_START_SERVER: "1" }, encoding: "utf8", timeout: 20000, killSignal: "SIGKILL" });
127
+ if (r.status !== 0 || Date.now() - t0 > 2000) log(`the JACK server ${name} answered ${r.status === 0 ? "" : "nothing "}in ${String(Date.now() - t0)} ms${r.status === 0 ? "" : `: ${(r.stderr ?? "").trim().split("\n").pop() ?? r.error?.code ?? ""}`}`);
128
+ return r.status === 0;
129
+ }
130
+
131
+ function locateCompareTool() {
132
+ const dirs = [fileURLToPath(new URL("../native/build/", import.meta.url)), "/usr/libexec/plugin-qualify/", "/usr/lib/plugin-qualify/"];
133
+ return process.env.OMX_JACK_COMPARE || dirs.map((d) => `${d}jack_compare`).find((p) => existsSync(p)) || "jack_compare";
134
+ }
135
+
136
+ /** The real-time priority a process here may take: what the JACK server and the hosts were granted. */
137
+ function realtimeGrant() {
138
+ try {
139
+ const line = readFileSync("/proc/self/limits", "utf8").split("\n").find((l) => l.startsWith("Max realtime priority"));
140
+ const soft = line?.split(/\s{2,}/)[1]?.trim();
141
+ return soft === "0" || soft === undefined ? "none (RLIMIT_RTPRIO 0: SCHED_OTHER)" : `rtprio ${soft}`;
142
+ } catch {
143
+ return "unknown";
144
+ }
145
+ }
146
+
147
+ /** The LV2 plugins lilv calls instruments: their stimulus is notes. */
148
+ function lv2Instruments(uris) {
149
+ if (uris.length === 0) return new Set();
150
+ const py = [
151
+ "import json,sys",
152
+ `sys.path.insert(0, ${JSON.stringify(fileURLToPath(new URL(".", import.meta.url)))})`,
153
+ "from lilv_load import load_lilv",
154
+ "lilv=load_lilv()",
155
+ "w=lilv.World();w.load_all()",
156
+ "want=set(json.load(sys.stdin));out=[]",
157
+ "for p in w.get_all_plugins():",
158
+ " u=str(p.get_uri())",
159
+ " if u in want and str(p.get_class().get_uri()).endswith('#InstrumentPlugin'): out.append(u)",
160
+ "print(json.dumps(out))",
161
+ ].join("\n");
162
+ const r = spawnSync("python3", ["-c", py], { input: JSON.stringify(uris), encoding: "utf8", timeout: 120000 });
163
+ if (r.status !== 0) {
164
+ log(`the LV2 classes could not be read (${(r.stderr || "").trim().split("\n").pop()}): instruments are found by their ports`);
165
+ return new Set();
166
+ }
167
+ return new Set(JSON.parse(r.stdout));
168
+ }
169
+
170
+ // a CLAP target's uri is urn:clap:<id>; the hosts take clap:<path>#<id>
171
+ const addSpec = (t) => (t.format === "clap" ? `clap:${t.binaryPath}#${t.uri.replace(/^urn:clap:/, "")}` : t.uri);
172
+
173
+ function readRecords(dir) {
174
+ const out = [];
175
+ for (const f of existsSync(dir) ? readdirSync(dir).filter((n) => n.endsWith(COMPARISON_RECORD_SUFFIX)).sort() : []) {
176
+ const rec = JSON.parse(readFileSync(join(dir, f), "utf8"));
177
+ const problems = comparisonRecordProblems(rec);
178
+ if (problems.length) throw new Error(`${join(dir, f)}: ${problems.join("; ")}`);
179
+ out.push(rec);
180
+ }
181
+ return out;
182
+ }
183
+
184
+ function writeSummary(dir, records, present, meta, format) {
185
+ const summary = summarise(records, present, meta);
186
+ if (dir !== undefined) {
187
+ writeFileWhole(join(dir, COMPARISON_SUMMARY_FILE), `${JSON.stringify(summary, null, 2)}\n`);
188
+ log(`summary ${join(dir, COMPARISON_SUMMARY_FILE)}`);
189
+ }
190
+ process.stdout.write(format === "json" ? `${JSON.stringify(summary, null, 2)}\n` : summaryTable(summary));
191
+ return summary;
192
+ }
193
+
194
+ export async function compareSummaryMain(argv) {
195
+ const parsed = parseSummaryArgs(argv);
196
+ if (!parsed.ok) {
197
+ process.stderr.write(`plugin-qualify compare summary: ${parsed.error}\n\n${summaryHelpText()}`);
198
+ return 2;
199
+ }
200
+ if (parsed.args.help) {
201
+ process.stdout.write(summaryHelpText());
202
+ return 0;
203
+ }
204
+ const records = readRecords(parsed.args.dir);
205
+ if (records.length === 0) {
206
+ log(`no ${COMPARISON_RECORD_SUFFIX} in ${parsed.args.dir}`);
207
+ return 2;
208
+ }
209
+ const present = presenceFromRecords(records);
210
+ const missing = missingReferences(present);
211
+ if (parsed.args.publish && missing.length) {
212
+ for (const m of missing) log(`not publishable: ${m.host}: ${m.reason}`);
213
+ return 1;
214
+ }
215
+ writeSummary(parsed.args.dir, records, present, { hostClass: parsed.args.hostClass, set: parsed.args.set, publish: parsed.args.publish }, "text");
216
+ return 0;
217
+ }
218
+
219
+ export async function compareMain(argv) {
220
+ if (argv[0] === "summary") return compareSummaryMain(argv.slice(1));
221
+ const parsed = parseCompareArgs(argv);
222
+ if (!parsed.ok) {
223
+ process.stderr.write(`plugin-qualify compare: ${parsed.error}\n\n${compareHelpText()}`);
224
+ return 2;
225
+ }
226
+ const a = parsed.args;
227
+ if (a.help) {
228
+ process.stdout.write(compareHelpText());
229
+ return 0;
230
+ }
231
+ const present = presence(a.hosts);
232
+ for (const p of present) log(p.installed ? `host ${p.host}: ${p.version ?? "version unknown"}` : `host ${p.host}: skipped, ${p.reason}`);
233
+ const missing = missingReferences(present);
234
+ if (a.publish && missing.length) {
235
+ // a publishable comparison is against both references: refused before anything runs, each missing one named
236
+ for (const m of missing) log(`--publish refused: ${m.host}: ${m.reason}`);
237
+ log(`install them (see the README, "Compare with mod-host and jalv"), or run without --publish for a local look`);
238
+ return 1;
239
+ }
240
+ let targets;
241
+ if (a.all) targets = discoverAllTargets();
242
+ else {
243
+ // an LV2 URI with a path in it (http://...) is a URI here, not a missing file: lv2Loader takes only the path-free ones
244
+ const isUri = (x) => /^[A-Za-z][A-Za-z0-9+.-]*:\S+$/.test(x) && !existsSync(x) && !x.endsWith(".clap") && !x.endsWith(".lv2");
245
+ const r = resolveTargets(a.targets.filter((x) => !isUri(x)), [clapLoader(), lv2Loader]);
246
+ if (!r.ok) {
247
+ log(`no plugin found for: ${r.refused.join(", ")}`);
248
+ return 2;
249
+ }
250
+ targets = [...a.targets.filter(isUri).map((uri) => ({ format: "lv2", uri })), ...r.plugins];
251
+ }
252
+ if (a.limit > 0) targets = targets.slice(0, a.limit);
253
+ if (targets.length === 0) {
254
+ log("no plugin to compare");
255
+ return 2;
256
+ }
257
+ log(`${String(targets.length)} plugins, ${String(present.filter((p) => p.installed).length)} hosts, ${String(a.rate)}/${String(a.quantum)}`);
258
+ // a JACK server maps about 100 MB of /dev/shm and its clients more: a container's default 64 MB runs out mid-run, and
259
+ // the server then stops answering
260
+ try {
261
+ const st = statfsSync("/dev/shm");
262
+ const free = (st.bavail * st.bsize) / 2 ** 20;
263
+ if (free < 512) log(`/dev/shm has ${String(Math.round(free))} MiB free; the JACK server and its clients want 512 or more (podman run --shm-size=1g)`);
264
+ } catch {
265
+ // no /dev/shm to read: JACK says so itself
266
+ }
267
+ const instruments = lv2Instruments(targets.filter((t) => t.format === "lv2").map((t) => t.uri));
268
+ let jack;
269
+ const startOwn = async () => (a.server === undefined ? startJack(a.rate, a.quantum) : { name: a.server, alive: () => answers(a.server), stop: async () => {} });
270
+ try {
271
+ jack = await startOwn();
272
+ } catch (e) {
273
+ log(e.message);
274
+ return 2;
275
+ }
276
+ const jackVersion = (() => {
277
+ const r = sh("jackd", ["--version"]);
278
+ return (`${r.stdout ?? ""}`.match(/jackdmp version (\S+)/) ?? [])[1] ?? "unknown";
279
+ })();
280
+ const conditions = { rate: a.rate, quantum: a.quantum, cycles: a.cycles, soakS: a.soakS, windowMs: a.windowMs, timeoutMs: a.timeoutMs, jack: `jackd ${jackVersion} ${a.server === undefined ? "dummy driver, private server" : `server ${a.server}`}`, realtime: realtimeGrant() };
281
+ if (a.out !== undefined) mkdirSync(a.out, { recursive: true });
282
+ const work = mkdtempSync(join(tmpdir(), "plugin-qualify-compare-"));
283
+ // a host step at most timeoutMs, cycles add and remove each, the run, two windows and the soak; then a margin
284
+ const budget = a.timeoutMs * (2 * a.cycles + 8) + a.soakS * 1000 + 2 * a.windowMs + 15000;
285
+ const records = [];
286
+ try {
287
+ for (const [i, t] of targets.entries()) {
288
+ const file = a.out === undefined ? undefined : join(a.out, comparisonFileName(t.format, t.uri));
289
+ if (file !== undefined && !a.force && existsSync(file)) {
290
+ try {
291
+ const old = JSON.parse(readFileSync(file, "utf8"));
292
+ if (comparisonRecordProblems(old).length === 0 && sameConditions(old.conditions, conditions)) {
293
+ log(`[${String(i + 1)}/${String(targets.length)}] ${t.uri}: compared already under these conditions, kept`);
294
+ records.push(old);
295
+ continue;
296
+ }
297
+ } catch {
298
+ // an unreadable record is measured again
299
+ }
300
+ }
301
+ const stimulus = t.format === "lv2" ? (instruments.has(t.uri) ? "notes" : "tone") : "tone";
302
+ const rows = [];
303
+ for (const p of present) {
304
+ if (!p.installed) {
305
+ rows.push({ host: p.host, version: null, status: "skipped", reason: p.reason });
306
+ continue;
307
+ }
308
+ if (!HOST_TRAITS[p.host].formats.includes(t.format)) {
309
+ rows.push(notApplicableRow(p.host, p.version, t.format));
310
+ continue;
311
+ }
312
+ // a server that stopped answering is started again, and the host's run on it is made again once: the
313
+ // server's failure is not the host's
314
+ let obs;
315
+ for (let attempt = 0; attempt < 2; attempt++) {
316
+ if (!jack.alive()) {
317
+ if (a.server !== undefined) {
318
+ obs = { installed: true, started: false, problem: `the JACK server ${a.server} does not answer` };
319
+ break;
320
+ }
321
+ log("the JACK server does not answer; starting a new one");
322
+ await jack.stop();
323
+ jack = await startOwn();
324
+ }
325
+ const opts = { server: jack.name, cycles: a.cycles, soakS: a.soakS, windowMs: a.windowMs, timeoutMs: a.timeoutMs, stimulus, workdir: join(work, p.host) };
326
+ const r = spawnSync(process.execPath, [DRIVER, p.host, addSpec(t), JSON.stringify(opts)], { encoding: "utf8", timeout: budget, killSignal: "SIGKILL" });
327
+ try {
328
+ obs = JSON.parse(r.stdout.trim().split("\n").pop() ?? "");
329
+ } catch {
330
+ obs = { installed: true, started: false, problem: r.error?.code === "ETIMEDOUT" ? `the comparison of this plugin went past its budget of ${String(Math.round(budget / 1000))} s` : `the driver gave no result: ${(r.stderr ?? "").trim().split("\n").pop() ?? ""}` };
331
+ }
332
+ if (jack.alive()) break;
333
+ log(`${t.uri} ${p.host}: the JACK server stopped answering during the run${attempt === 0 ? "; once more on a new server" : ""}`);
334
+ if (attempt === 1) obs = { installed: true, started: false, problem: "the JACK server stopped answering during this host's run, twice" };
335
+ }
336
+ const row = hostRowOf(p.host, p.version, obs, conditions);
337
+ rows.push(row);
338
+ const c = row.checks;
339
+ log(
340
+ `[${String(i + 1)}/${String(targets.length)}] ${t.uri} ${p.host}: ${row.status === "ran" ? `${row.runsClean ? "clean" : "not clean"}; load ${c.loads.result}, output ${c.output.result}, cycles ${c.cycles.result}, soak ${c.soak.result}, rt ${c.rtSafe.result}${row.crash ? `, crashed at ${row.crash.step} (${row.crash.signal})` : ""}` : `${row.status}: ${row.reason}`}`,
341
+ );
342
+ }
343
+ const rec = comparisonRecordOf({ format: t.format, id: t.uri, ...(t.binary ? { binary: t.binary } : {}) }, stimulus, conditions, new Date().toISOString(), rows);
344
+ records.push(rec);
345
+ if (file !== undefined) writeFileWhole(file, `${JSON.stringify(rec, null, 2)}\n`);
346
+ }
347
+ } finally {
348
+ await jack.stop();
349
+ rmSync(work, { recursive: true, force: true });
350
+ }
351
+ log(`machine: ${String(cpus().length)} CPUs, ${cpus()[0]?.model ?? "unknown CPU"}; real time ${conditions.realtime}`);
352
+ const all = a.out === undefined ? records : readRecords(a.out);
353
+ writeSummary(a.out, all, present, { hostClass: a.hostClass, set: a.set, publish: a.publish }, a.format);
354
+ return 0;
355
+ }
@@ -109,6 +109,33 @@ def schema_candidates(env=None):
109
109
  ]
110
110
 
111
111
 
112
+ SCHEMA_MAJOR = re.search(r"\d+", DESCRIPTOR_SCHEMA_FLOOR).group(0)
113
+ UNITS_SHEET = os.path.join("formats", "lv2", "units.json")
114
+
115
+
116
+ def contract_data_candidates(rel, env=None):
117
+ """Where one of omx-contract's data files is looked for: in the same omx-contract as each place the schema is,
118
+ its package's `data/` beside `schema/`, or the contract directory beside the installed schema."""
119
+ out = []
120
+ for schema in schema_candidates(env):
121
+ here = os.path.dirname(schema)
122
+ out.append(os.path.join(os.path.dirname(here), "data", rel))
123
+ out.append(os.path.join(here, "contract.d", SCHEMA_MAJOR, "omx-contract", rel))
124
+ return out
125
+
126
+
127
+ def unit_words(env=None):
128
+ """omx-contract's units sheet as LV2 unit URI -> the word it is spelled by; a unit the sheet gives no word has
129
+ none here. The sheet is the owner: there is no table of units in this package."""
130
+ found = [p for p in contract_data_candidates(UNITS_SHEET, env) if os.path.isfile(p)]
131
+ if not found:
132
+ raise DescriptorRefused(DESCRIPTOR_CODE_SCHEMA_MISSING,
133
+ f"no omx-contract units sheet at {', '.join(contract_data_candidates(UNITS_SHEET, env))}")
134
+ with open(found[0], encoding="utf-8") as f:
135
+ terms = json.load(f)["LV2_UNITS_TERMS"]["terms"]
136
+ return {t["uri"]: t["word"] for t in terms.values() if t.get("kind") == "unit" and "word" in t}
137
+
138
+
112
139
  class SchemaValidator:
113
140
  """omx-contract's schema, compiled once; `check` refuses a document it does not hold."""
114
141
 
@@ -10,6 +10,8 @@ import { createConnection } from "node:net";
10
10
  import { tmpdir } from "node:os";
11
11
  import { join } from "node:path";
12
12
 
13
+ import { pinWord } from "../dist/pins-file.js";
14
+
13
15
  const [uri, windowMsWord] = process.argv.slice(2);
14
16
  const windowMs = Number(windowMsWord);
15
17
  const signalTool = process.env.OMX_ISOLATED_SIGNAL;
@@ -80,7 +82,7 @@ async function main() {
80
82
  const sock = await connect();
81
83
  if (sock === undefined) return { started: false, problem: `${hostd} did not listen` };
82
84
  const send = commander(sock);
83
- const added = await send(`add ${uri} ${String(INSTANCE)}`);
85
+ const added = await send(`add ${pinWord(uri)} ${String(INSTANCE)}`);
84
86
  // WORKER_SPAWN: no worker came up to hold the plugin, which says nothing of the plugin. The next pass tries it again.
85
87
  if (added?.code === WORKER_SPAWN) return { started: false, problem: `no worker came up: add answered ${String(WORKER_SPAWN)}` };
86
88
  if (added === undefined || added.code < 0) return { started: true, added: false, problem: added === undefined ? "the host gave no answer to add" : `add answered ${String(added.code)}` };
@@ -0,0 +1,25 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+ # Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ """lilv's Python bindings, with the library loaded by its soname."""
4
+ import ctypes
5
+
6
+ LILV_DEV_NAME = "liblilv-0.so"
7
+ LILV_SONAME = "liblilv-0.so.0"
8
+
9
+
10
+ def load_lilv():
11
+ """
12
+ The bindings module. Fedora's and Debian's lilv.py open liblilv-0.so, a name only their development packages
13
+ install, so it is opened as liblilv-0.so.0 during the import. Raises what the import raises.
14
+ """
15
+ real = ctypes.CDLL
16
+
17
+ def by_soname(name, *args, **kwargs):
18
+ return real(LILV_SONAME if name == LILV_DEV_NAME else name, *args, **kwargs)
19
+
20
+ ctypes.CDLL = by_soname
21
+ try:
22
+ import lilv
23
+ finally:
24
+ ctypes.CDLL = real
25
+ return lilv
@@ -25,7 +25,9 @@ def path_of(uri):
25
25
 
26
26
  def main():
27
27
  try:
28
- import lilv
28
+ from lilv_load import load_lilv
29
+
30
+ lilv = load_lilv()
29
31
  except ImportError as e:
30
32
  print(f"lv2_resolve: lilv's Python bindings are not installed: {e}", file=sys.stderr)
31
33
  return 2
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: GPL-3.0-or-later
3
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
4
+ // The pool step: does the CLAP thread pool help this plugin, and is it safe? A plugin-hostd of its own on the JACK server
5
+ // the environment names. The plugin is added once so the daemon learns its pin, and the pin is then set twice, `pool=off`
6
+ // and `pool=cores`, twice each in turn: each time the plugin is added alone in a worker (the daemon starts that worker with
7
+ // the thread pool on only for `pool=cores`), the same notes are played through it for a window, and the window's xruns,
8
+ // its output and the server's DSP load are read; a mode is judged by its best window. Prints one JSON line for src/qualify-pool.ts to judge. A host that never listened says so:
9
+ // nothing was learned about the plugin then. That the plugin asks for the pool at all is the caller's to know first.
10
+ //
11
+ // pool-step.mjs <clap uri> <window ms>
12
+ // environment: OMX_ISOLATED_SIGNAL (the signal tool), PLUGIN_HOSTD
13
+ import { spawn, spawnSync } from "node:child_process";
14
+ import { mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
15
+ import { createConnection } from "node:net";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+
19
+ import { pinWord, pinWords } from "../dist/pins-file.js";
20
+
21
+ const [uri, windowMsWord] = process.argv.slice(2);
22
+ const windowMs = Number(windowMsWord);
23
+ const signalTool = process.env.OMX_ISOLATED_SIGNAL;
24
+ const hostd = process.env.PLUGIN_HOSTD ?? "plugin-hostd";
25
+ const port = 40000 + (process.pid % 20000);
26
+ const CONNECT_TRIES = 50;
27
+ const COMMAND_MS = Number(process.env.OMX_ADMIT_STEP_MS ?? 0) || 30000;
28
+ const INSTANCE = 0;
29
+ const WORKER_SPAWN = -503;
30
+ /** How many times each mode is run, the two alternating. */
31
+ const ROUNDS = 2;
32
+ /** hostd's worker_list field for a worker started with the thread pool on. */
33
+ const POOL_ON = "on";
34
+
35
+ const state = mkdtempSync(join(tmpdir(), "plugin-qualify-pool-"));
36
+ const host = spawn(hostd, ["-n", "-p", String(port)], { stdio: "ignore", detached: true, env: { ...process.env, XDG_STATE_HOME: state } });
37
+ let ended = false;
38
+ host.on("exit", () => (ended = true));
39
+ host.on("error", () => (ended = true));
40
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
41
+
42
+ async function connect() {
43
+ for (let i = 0; i < CONNECT_TRIES && !ended; i++) {
44
+ const s = await new Promise((resolve) => {
45
+ const c = createConnection({ port, host: "127.0.0.1" }, () => resolve(c));
46
+ c.on("error", () => resolve(undefined));
47
+ });
48
+ if (s !== undefined) return s;
49
+ await sleep(100);
50
+ }
51
+ return undefined;
52
+ }
53
+
54
+ function commander(sock) {
55
+ let buffer = "";
56
+ let closed = false;
57
+ sock.on("data", (d) => (buffer += d.toString()));
58
+ sock.on("close", () => (closed = true));
59
+ sock.on("error", () => (closed = true));
60
+ return async (line) => {
61
+ buffer = "";
62
+ sock.write(`${line}\0`);
63
+ const until = Date.now() + COMMAND_MS;
64
+ while (Date.now() < until && !closed) {
65
+ const m = /resp (-?\d+)([^\0]*)\0?/.exec(buffer);
66
+ if (m !== null && buffer.includes("\0")) return { code: Number(m[1]), rest: m[2].trim() };
67
+ await sleep(20);
68
+ }
69
+ return undefined;
70
+ };
71
+ }
72
+
73
+ /** `<worker>:<pid>:<format>:<state>:<core>:<pool>:<place>:<i>,<i>,...` per worker. */
74
+ const workersOf = (answer) =>
75
+ (answer?.rest ?? "").split(/\s+/).filter(Boolean).map((w) => {
76
+ const f = w.split(":");
77
+ return { pid: Number(f[1]), pool: f[5], instances: (f[7] ?? "").split(",").filter(Boolean) };
78
+ });
79
+
80
+ /** The pin the daemon learned for the uri, as its store holds it: the files and the layout words, and none of the optional ones. */
81
+ function learnedPin() {
82
+ let text;
83
+ try {
84
+ text = readFileSync(join(state, "plugin-hostd", "pins"), "utf8");
85
+ } catch {
86
+ return undefined;
87
+ }
88
+ for (const line of text.split("\n")) {
89
+ // the store writes the uri as it is: look for it as a prefix, whatever it holds
90
+ const head = `pin_set ${uri} `;
91
+ const quoted = `pin_set ${pinWord(uri)} `;
92
+ const rest = line.startsWith(quoted) ? line.slice(quoted.length) : line.startsWith(head) ? line.slice(head.length) : undefined;
93
+ if (rest === undefined) continue;
94
+ const words = pinWords(rest);
95
+ if (words.length >= 2) return { files: words[0], layout: words[1] };
96
+ }
97
+ return undefined;
98
+ }
99
+
100
+ /** Nanoseconds the pool's helper threads of a worker have been on a cpu (`omx-pool-<core>`), or `undefined` when the worker is gone. */
101
+ function helperNs(pid) {
102
+ try {
103
+ let ns = 0;
104
+ for (const t of readdirSync(`/proc/${String(pid)}/task`)) {
105
+ if (!readFileSync(`/proc/${String(pid)}/task/${t}/comm`, "utf8").startsWith("omx-pool-")) continue;
106
+ ns += Number(readFileSync(`/proc/${String(pid)}/task/${t}/schedstat`, "utf8").split(" ")[0]);
107
+ }
108
+ return ns;
109
+ } catch {
110
+ return undefined;
111
+ }
112
+ }
113
+
114
+ /** One window: the plugin added alone under `pool=<word>`, the notes played, what was seen. */
115
+ async function window(send, pool, pin) {
116
+ const set = await send(`pin_set ${pinWord(uri)} ${pinWord(pin.files)} ${pin.layout} pool=${pool}`);
117
+ if (set === undefined || set.code !== 0) return { problem: `pin_set pool=${pool} answered ${set === undefined ? "nothing" : String(set.code)}` };
118
+ const added = await send(`add ${pinWord(uri)} ${String(INSTANCE)}`);
119
+ if (added?.code === WORKER_SPAWN) return { problem: `no worker came up: add answered ${String(WORKER_SPAWN)}` };
120
+ if (added === undefined || added.code < 0) return { problem: added === undefined ? "the host gave no answer to add" : `add answered ${String(added.code)}` };
121
+ const workers = workersOf(await send("worker_list"));
122
+ const worker = workers[0];
123
+ const heard = spawnSync(signalTool, [`effect_${String(INSTANCE)}`, String(windowMs)], { encoding: "utf8", timeout: windowMs + 15000, killSignal: "SIGKILL" });
124
+ const helperBusyNs = worker === undefined ? undefined : helperNs(worker.pid);
125
+ const after = workersOf(await send("worker_list"));
126
+ await send(`remove ${String(INSTANCE)}`);
127
+ const alive = after.length > 0 && after[0].pid === worker?.pid;
128
+ if (heard.error !== undefined || heard.status !== 0) return { problem: `isolated_signal: ${(heard.stderr ?? "").trim()}`, alive };
129
+ const f = JSON.parse(heard.stdout);
130
+ return { alive, workers: workers.length, poolOfWorker: worker?.pool, xruns: f.xruns, finite: f.finite, silent: f.silent, dspLoad: f.dspLoad, ...(helperBusyNs === undefined ? {} : { helperBusyNs }) };
131
+ }
132
+
133
+ /** The windows of one mode as one: the lowest load and the fewest xruns, and any window that failed, died or was silent counts. */
134
+ function best(windows) {
135
+ const failed = windows.find((w) => w.problem !== undefined);
136
+ if (failed !== undefined) return failed;
137
+ const low = (key) => Math.min(...windows.map((w) => w[key]));
138
+ return {
139
+ alive: windows.every((w) => w.alive),
140
+ workers: windows[0].workers,
141
+ poolOfWorker: windows[0].poolOfWorker,
142
+ xruns: low("xruns"),
143
+ finite: windows.every((w) => w.finite),
144
+ silent: windows.some((w) => w.silent),
145
+ dspLoad: low("dspLoad"),
146
+ // what the helpers did in the window that used them most: the pool is idle only if it was idle in every one
147
+ ...(windows.every((w) => w.helperBusyNs === undefined) ? {} : { helperBusyNs: Math.max(...windows.map((w) => w.helperBusyNs ?? 0)) }),
148
+ };
149
+ }
150
+
151
+ async function main() {
152
+ const sock = await connect();
153
+ if (sock === undefined) return { started: false, problem: `${hostd} did not listen` };
154
+ const send = commander(sock);
155
+ // the first add teaches the daemon the plugin's pin; the store holds it once the plugin is in
156
+ const first = await send(`add ${pinWord(uri)} ${String(INSTANCE)}`);
157
+ if (first?.code === WORKER_SPAWN) return { started: false, problem: `no worker came up: add answered ${String(WORKER_SPAWN)}` };
158
+ if (first === undefined || first.code < 0) return { started: true, added: false, problem: first === undefined ? "the host gave no answer to add" : `add answered ${String(first.code)}` };
159
+ await send(`remove ${String(INSTANCE)}`);
160
+ await sleep(300);
161
+ const pin = learnedPin();
162
+ if (pin === undefined) return { started: false, problem: "the daemon kept no pin of the plugin" };
163
+ // the two modes alternate, and each is read at its best window: the cost of a block only ever grows with what else runs on
164
+ // the machine, so the lowest load of a mode is the one least disturbed, and a pool that causes xruns causes them in
165
+ // every window it runs
166
+ const offs = [];
167
+ const ons = [];
168
+ for (let round = 0; round < ROUNDS; round++) {
169
+ offs.push(await window(send, "off", pin));
170
+ ons.push(await window(send, "cores", pin));
171
+ }
172
+ const off = best(offs);
173
+ const on = best(ons);
174
+ sock.destroy();
175
+ return { started: true, added: true, off, on, poolApplied: on.poolOfWorker === POOL_ON && off.poolOfWorker !== POOL_ON };
176
+ }
177
+
178
+ const result = await main();
179
+ try {
180
+ process.kill(-host.pid, "SIGKILL");
181
+ } catch {
182
+ // the host went on its own
183
+ }
184
+ rmSync(state, { recursive: true, force: true });
185
+ process.stdout.write(`${JSON.stringify(result)}\n`);
186
+ process.exit(0);