@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,432 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: GPL-3.0-or-later
3
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
4
+ // One plugin under one host, for `plugin-qualify compare`: the same steps for every host, and one JSON line of what was
5
+ // seen (src/compare.ts `HostObservation`); no judgment here, src/compare.ts `hostRowOf` makes the row.
6
+ //
7
+ // compare-host.mjs <host> <add spec> <options JSON>
8
+ //
9
+ // <host> is omx-clap-host, plugin-hostd or mod-host (driven over the mod-host socket protocol) or jalv (one process per
10
+ // plugin). <add spec> is what `add` takes: an LV2 URI, or clap:<path>#<id>. The options: server (the JACK server's name),
11
+ // cycles, soakS, windowMs, timeoutMs, stimulus (tone | notes | none: what the plugin's class asks for; the ports decide
12
+ // when the plugin has no input of that kind), workdir.
13
+ //
14
+ // The steps: start the host; load and unload the plugin `cycles` times (the first load is the load check, its ports on
15
+ // the graph the instantiation check); load it again, feed it its stimulus, read the peak of its outputs over a window,
16
+ // keep it running `soakS` seconds, read the peak again, the CPU its processes used over the soak, the latency on its
17
+ // output port, then ask every process of the host for its rt_watch.so report (SIGUSR2) and end it. Every host starts
18
+ // under LD_PRELOAD=rt_watch.so in a process group of its own, which the end takes whole.
19
+ import { spawn, spawnSync } from "node:child_process";
20
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
21
+ import { createConnection } from "node:net";
22
+ import { join } from "node:path";
23
+ import { fileURLToPath } from "node:url";
24
+
25
+ const [host, spec, optText] = process.argv.slice(2);
26
+ const opt = JSON.parse(optText ?? "{}");
27
+ const STEP_MS = opt.timeoutMs ?? 10000;
28
+ const WINDOW_MS = opt.windowMs ?? 2000;
29
+ const SOAK_S = opt.soakS ?? 10;
30
+ const CYCLES = opt.cycles ?? 5;
31
+ const WORK = opt.workdir;
32
+ const RT_DIR = join(WORK, "rt");
33
+ const INSTANCE = 0;
34
+ const CLK_TCK = 100;
35
+
36
+ const TOOL_DIRS = [fileURLToPath(new URL("../native/build/", import.meta.url)), "/usr/libexec/plugin-qualify/", "/usr/lib/plugin-qualify/"];
37
+ const tool = (name, env) => process.env[env] || TOOL_DIRS.map((d) => `${d}${name}`).find((p) => existsSync(p));
38
+ const JACK_PEAK = tool("jack_peak", "OMX_JACK_PEAK");
39
+ const JACK_COMPARE = tool("jack_compare", "OMX_JACK_COMPARE");
40
+ const RT_WATCH = tool("rt_watch.so", "OMX_RT_WATCH");
41
+
42
+ const jackEnv = { ...process.env, JACK_DEFAULT_SERVER: opt.server, JACK_NO_START_SERVER: "1" };
43
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
44
+ const hostEnv = () => {
45
+ const env = { ...jackEnv };
46
+ if (RT_WATCH) {
47
+ env.LD_PRELOAD = [RT_WATCH, process.env.LD_PRELOAD].filter(Boolean).join(" ");
48
+ env.OMX_RT_WATCH_DIR = RT_DIR;
49
+ }
50
+ return env;
51
+ };
52
+
53
+ // ------------------------------------------------------------------ the graph
54
+
55
+ function ports() {
56
+ const r = spawnSync(JACK_COMPARE, ["ports"], { env: jackEnv, encoding: "utf8", timeout: STEP_MS });
57
+ if (r.status !== 0) return undefined;
58
+ try {
59
+ return JSON.parse(r.stdout);
60
+ } catch {
61
+ return undefined;
62
+ }
63
+ }
64
+
65
+ /** The ports that came after `before`, of one client: the plugin's. */
66
+ function newPorts(before, client) {
67
+ const had = new Set(before.map((p) => p.name));
68
+ const now = ports() ?? [];
69
+ return now.filter((p) => !had.has(p.name) && !p.name.startsWith("jack_compare:") && !p.name.startsWith("jack_peak:") && (client === undefined || p.name.startsWith(`${client}:`)));
70
+ }
71
+
72
+ function portCounts(list) {
73
+ return {
74
+ audioIn: list.filter((p) => p.type === "audio" && p.input).length,
75
+ audioOut: list.filter((p) => p.type === "audio" && !p.input).length,
76
+ midiIn: list.filter((p) => p.type === "midi" && p.input).length,
77
+ };
78
+ }
79
+
80
+ /** Waits for the plugin's ports; the list stays empty when none came in time or the host ended. */
81
+ async function awaitPorts(before, client, alive) {
82
+ const until = Date.now() + STEP_MS;
83
+ let last = [];
84
+ while (Date.now() < until && alive()) {
85
+ const got = newPorts(before, client);
86
+ // a moment more once ports appear, so a plugin's late ports are counted too
87
+ if (got.length > 0 && got.length === last.length) return got;
88
+ last = got;
89
+ await sleep(got.length > 0 ? 150 : 100);
90
+ }
91
+ return last;
92
+ }
93
+
94
+ /** A long-running JACK helper that prints "ready": the stimulus, or the peak meter. */
95
+ async function helper(bin, args) {
96
+ if (bin === undefined) return { problem: `${args[0] === undefined ? "a tool" : "jack_compare"} is not installed` };
97
+ const p = spawn(bin, args, { env: jackEnv, stdio: ["pipe", "pipe", "pipe"] });
98
+ let out = "";
99
+ let err = "";
100
+ const ended = new Promise((resolve) => {
101
+ p.on("error", (e) => resolve(e.code ?? "error"));
102
+ p.on("exit", (code) => resolve(code));
103
+ });
104
+ p.stdout.on("data", (d) => (out += d.toString()));
105
+ p.stderr.on("data", (d) => (err += d.toString()));
106
+ const until = Date.now() + STEP_MS;
107
+ while (!out.includes("ready") && Date.now() < until && p.exitCode === null) await sleep(10);
108
+ if (!out.includes("ready")) {
109
+ p.kill("SIGKILL");
110
+ await ended;
111
+ return { problem: err.trim() || `${bin} did not start` };
112
+ }
113
+ return {
114
+ stop: async () => {
115
+ p.stdin.end();
116
+ await Promise.race([ended, sleep(STEP_MS)]);
117
+ p.kill("SIGKILL");
118
+ return { out: out.slice(out.indexOf("ready") + 5), err };
119
+ },
120
+ };
121
+ }
122
+
123
+ /** The peak of `outs` over the window. */
124
+ async function peakOver(outs) {
125
+ if (JACK_PEAK === undefined) return { problem: "jack_peak is not installed" };
126
+ const m = await helper(JACK_PEAK, outs.map((p) => p.name));
127
+ if (m.problem) return m;
128
+ await sleep(WINDOW_MS);
129
+ const r = await m.stop();
130
+ try {
131
+ const j = JSON.parse(r.out);
132
+ return { peak: j.peak, nonFinite: j.nonFinite ?? 0 };
133
+ } catch {
134
+ return { problem: r.err.trim() || "jack_peak gave no figure" };
135
+ }
136
+ }
137
+
138
+ function latencyOf(outs) {
139
+ if (outs.length === 0 || JACK_COMPARE === undefined) return null;
140
+ const r = spawnSync(JACK_COMPARE, ["latency", outs[0].name], { env: jackEnv, encoding: "utf8", timeout: STEP_MS });
141
+ try {
142
+ return JSON.parse(r.stdout).capture[1];
143
+ } catch {
144
+ return null;
145
+ }
146
+ }
147
+
148
+ // ------------------------------------------------------------------ the host's processes
149
+
150
+ /** The pids of a process group. */
151
+ function groupPids(pgid) {
152
+ const out = [];
153
+ for (const d of readdirSync("/proc")) {
154
+ if (!/^\d+$/.test(d)) continue;
155
+ try {
156
+ const stat = readFileSync(`/proc/${d}/stat`, "utf8");
157
+ const f = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
158
+ if (Number(f[2]) === pgid) out.push({ pid: Number(d), ticks: Number(f[11]) + Number(f[12]) });
159
+ } catch {
160
+ // gone between the list and the read
161
+ }
162
+ }
163
+ return out;
164
+ }
165
+ const groupTicks = (pgid) => groupPids(pgid).reduce((s, p) => s + p.ticks, 0);
166
+
167
+ /** Every process of the group writes its rt_watch.so report; the reports read back. */
168
+ async function rtReports(pgid) {
169
+ if (!RT_WATCH) return undefined;
170
+ try {
171
+ process.kill(-pgid, "SIGUSR2");
172
+ } catch {
173
+ return [];
174
+ }
175
+ await sleep(400);
176
+ const out = [];
177
+ for (const f of existsSync(RT_DIR) ? readdirSync(RT_DIR) : []) {
178
+ try {
179
+ out.push(JSON.parse(readFileSync(join(RT_DIR, f), "utf8")));
180
+ } catch {
181
+ // a report cut short is no report
182
+ }
183
+ }
184
+ return out;
185
+ }
186
+
187
+ /** What the plugin is fed: notes when its class says instrument, or when it takes MIDI and no audio; else a tone. */
188
+ function stimulusFor(want, counts) {
189
+ if (counts.midiIn > 0 && (want === "notes" || counts.audioIn === 0)) return "notes";
190
+ if (counts.audioIn > 0) return "tone";
191
+ return "none";
192
+ }
193
+
194
+ /** Feeds the stimulus, measures, soaks: the run every host gets once the plugin is in. */
195
+ async function run(plugin, pgid, alive, deathOf) {
196
+ const counts = portCounts(plugin);
197
+ const stimulus = stimulusFor(opt.stimulus, counts);
198
+ const targets = plugin.filter((p) => p.input && p.type === (stimulus === "notes" ? "midi" : "audio")).map((p) => p.name);
199
+ const outs = plugin.filter((p) => !p.input && p.type === "audio");
200
+ const obs = { stimulus };
201
+ let feed;
202
+ if (stimulus !== "none") {
203
+ feed = await helper(JACK_COMPARE, [stimulus, ...targets]);
204
+ if (feed.problem) obs.peakProblem = `the ${stimulus} could not be fed: ${feed.problem}`;
205
+ }
206
+ if (outs.length > 0 && obs.peakProblem === undefined) {
207
+ const p = await peakOver(outs);
208
+ if (p.problem) obs.peakProblem = p.problem;
209
+ else {
210
+ obs.peak = p.peak;
211
+ if (p.nonFinite) obs.nonFinite = p.nonFinite;
212
+ }
213
+ }
214
+ const t0 = groupTicks(pgid);
215
+ const s0 = Date.now();
216
+ while (Date.now() - s0 < SOAK_S * 1000 && alive()) await sleep(100);
217
+ const seconds = (Date.now() - s0) / 1000;
218
+ const t1 = groupTicks(pgid);
219
+ const survived = alive();
220
+ let peakAtEnd;
221
+ if (survived && outs.length > 0 && obs.peakProblem === undefined) {
222
+ const p = await peakOver(outs);
223
+ peakAtEnd = p.peak ?? null;
224
+ if (p.nonFinite) obs.nonFinite = (obs.nonFinite ?? 0) + p.nonFinite;
225
+ }
226
+ obs.soak = { seconds: Math.round(seconds * 10) / 10, alive: survived && alive(), ...(survived ? {} : { died: deathOf() }), ...(peakAtEnd === undefined ? {} : { peakAtEnd }) };
227
+ obs.cpu = survived ? { percent: (100 * (t1 - t0)) / CLK_TCK / Math.max(seconds, 0.001) } : { problem: "the host ended during the soak" };
228
+ obs.latencyFrames = survived ? latencyOf(outs) : null;
229
+ if (survived) obs.rt = await rtReports(pgid);
230
+ if (feed?.stop) await feed.stop();
231
+ return obs;
232
+ }
233
+
234
+ /** SIGTERM to the group, then SIGKILL to whatever of it is left after the step budget. */
235
+ async function endGroup(pgid, alive) {
236
+ try {
237
+ process.kill(-pgid, "SIGTERM");
238
+ } catch {
239
+ return;
240
+ }
241
+ const until = Date.now() + Math.min(STEP_MS, 5000);
242
+ while (Date.now() < until && (alive() || groupPids(pgid).length > 0)) await sleep(50);
243
+ try {
244
+ process.kill(-pgid, "SIGKILL");
245
+ } catch {}
246
+ }
247
+
248
+ // ------------------------------------------------------------------ the protocol hosts
249
+
250
+ async function protocolHost() {
251
+ const port = 20000 + (process.pid % 20000);
252
+ const args = ["-n", "-p", String(port)];
253
+ if (host === "plugin-hostd") {
254
+ // the comparison asks plugin-hostd to host, not to judge: the pins are plugin-qualify's other job
255
+ const conf = join(WORK, "plugin-hostd.conf");
256
+ writeFileSync(conf, "require_pins 0\n");
257
+ args.push("-c", conf);
258
+ }
259
+ const before0 = ports();
260
+ if (before0 === undefined) return { installed: true, started: false, problem: "no JACK server answers" };
261
+ const h = spawn(host, args, { stdio: ["ignore", "ignore", "pipe"], detached: true, env: hostEnv() });
262
+ let end;
263
+ let stderr = "";
264
+ h.stderr.on("data", (d) => (stderr = (stderr + d.toString()).slice(-4000)));
265
+ h.on("exit", (code, signal) => (end = signal ?? `exit ${String(code)}`));
266
+ const failed = await Promise.race([new Promise((r) => h.on("error", (e) => r(e.code))), sleep(200).then(() => undefined)]);
267
+ if (failed === "ENOENT") return { installed: false };
268
+ const alive = () => end === undefined;
269
+ const deathOf = () => end ?? "alive";
270
+ let sock;
271
+ for (let i = 0; i < 50 && alive() && sock === undefined; i++) {
272
+ sock = await new Promise((resolve) => {
273
+ const c = createConnection({ port, host: "127.0.0.1" }, () => resolve(c));
274
+ c.on("error", () => resolve(undefined));
275
+ });
276
+ if (sock === undefined) await sleep(100);
277
+ }
278
+ if (sock === undefined) {
279
+ try {
280
+ process.kill(-h.pid, "SIGKILL");
281
+ } catch {}
282
+ return { installed: true, started: false, problem: `${host} did not listen (${end ?? "no socket"}): ${stderr.trim().split("\n").pop() ?? ""}` };
283
+ }
284
+ let buffer = "";
285
+ let closed = false;
286
+ sock.on("data", (d) => (buffer += d.toString()));
287
+ sock.on("close", () => (closed = true));
288
+ sock.on("error", () => (closed = true));
289
+ const ask = async (line) => {
290
+ buffer = "";
291
+ sock.write(`${line}\0`);
292
+ const until = Date.now() + STEP_MS;
293
+ while (Date.now() < until && !closed && alive()) {
294
+ const m = /resp (-?\d+)/.exec(buffer);
295
+ if (m !== null) return { resp: Number(m[1]) };
296
+ await sleep(20);
297
+ }
298
+ await sleep(150); // a host that dies on the command closes the socket a moment before it is reaped
299
+ return { died: end ?? (closed ? "closed" : undefined) };
300
+ };
301
+ const obs = { installed: true, started: true };
302
+ // the host is asked to end (SIGTERM, so it closes its JACK clients) and killed only when it does not in time: a client
303
+ // killed in the middle of a cycle can leave a JACK server waiting on it
304
+ const finish = async () => {
305
+ sock.destroy();
306
+ await endGroup(h.pid, alive);
307
+ return obs;
308
+ };
309
+ const crashed = (step) => {
310
+ const sig = end ?? "closed";
311
+ obs.died = { step, signal: sig };
312
+ };
313
+
314
+ // the cycles: the first add is the load check, its ports the instantiation check
315
+ let done = 0;
316
+ for (let i = 0; i < CYCLES; i++) {
317
+ const base = ports() ?? [];
318
+ const a = await ask(`add ${spec} ${INSTANCE}`);
319
+ if (i === 0) obs.load = a.resp !== undefined ? { ok: a.resp >= 0, resp: a.resp } : { ok: false, ...(a.died ? { died: a.died } : { detail: "no answer in time" }) };
320
+ if (a.resp === undefined || a.resp < 0) {
321
+ obs.cycles = { done, of: CYCLES, failedAt: "add", ...(a.died ? { died: a.died } : {}) };
322
+ if (!alive() || closed) crashed(i === 0 ? "load" : `cycle ${String(i + 1)}`);
323
+ return await finish();
324
+ }
325
+ const got = await awaitPorts(base, undefined, alive);
326
+ if (i === 0) obs.ports = portCounts(got);
327
+ const r = await ask(`remove ${INSTANCE}`);
328
+ if (r.resp === undefined || r.resp < 0) {
329
+ obs.cycles = { done, of: CYCLES, failedAt: "remove", ...(r.died ? { died: r.died } : {}) };
330
+ if (!alive() || closed) crashed(`cycle ${String(i + 1)}`);
331
+ return await finish();
332
+ }
333
+ done++;
334
+ }
335
+ obs.cycles = { done, of: CYCLES };
336
+
337
+ // the run
338
+ const base = ports() ?? [];
339
+ const a = await ask(`add ${spec} ${INSTANCE}`);
340
+ if (a.resp === undefined || a.resp < 0) {
341
+ if (!alive() || closed) crashed("run");
342
+ obs.soak = { seconds: 0, alive: false, died: a.died ?? `resp ${String(a.resp)}` };
343
+ return await finish();
344
+ }
345
+ const plugin = await awaitPorts(base, undefined, alive);
346
+ Object.assign(obs, await run(plugin, h.pid, () => alive() && !closed, deathOf));
347
+ if (!alive()) crashed("soak");
348
+ else await ask(`remove ${INSTANCE}`);
349
+ return await finish();
350
+ }
351
+
352
+ // ------------------------------------------------------------------ jalv: one process per plugin
353
+
354
+ async function jalvHost() {
355
+ if (spawnSync("jalv", ["-V"], { stdio: "ignore" }).error?.code === "ENOENT") return { installed: false };
356
+ if (ports() === undefined) return { installed: true, started: false, problem: "no JACK server answers" };
357
+ const obs = { installed: true, started: true };
358
+ const start = (k) => {
359
+ const name = `cmp${String(process.pid)}_${String(k)}`;
360
+ const p = spawn("jalv", ["-i", "-x", "-n", name, spec], { stdio: ["ignore", "ignore", "pipe"], detached: true, env: hostEnv() });
361
+ const st = { p, name, end: undefined, stderr: "" };
362
+ p.stderr.on("data", (d) => (st.stderr = (st.stderr + d.toString()).slice(-4000)));
363
+ p.on("exit", (code, signal) => (st.end = signal ?? `exit ${String(code)}`));
364
+ return st;
365
+ };
366
+ const stop = async (st) => {
367
+ try {
368
+ process.kill(-st.p.pid, "SIGTERM");
369
+ } catch {}
370
+ const until = Date.now() + STEP_MS;
371
+ while (st.end === undefined && Date.now() < until) await sleep(20);
372
+ if (st.end === undefined) {
373
+ try {
374
+ process.kill(-st.p.pid, "SIGKILL");
375
+ } catch {}
376
+ return "hung";
377
+ }
378
+ // ended by the SIGTERM it was sent, or exited cleanly on it
379
+ return st.end === "SIGTERM" || st.end === "exit 0" ? "ok" : st.end;
380
+ };
381
+ const crashSignal = (end) => end !== undefined && /^SIG/.test(end) && end !== "SIGTERM" && end !== "SIGKILL";
382
+
383
+ let done = 0;
384
+ for (let i = 0; i < CYCLES; i++) {
385
+ const base = ports() ?? [];
386
+ const st = start(i);
387
+ const got = await awaitPorts(base, st.name, () => st.end === undefined);
388
+ if (i === 0) {
389
+ obs.load = got.length > 0 ? { ok: true } : { ok: false, ...(st.end ? { died: st.end } : {}), detail: st.stderr.trim().split("\n").filter(Boolean).pop() ?? "no client on the graph in time" };
390
+ obs.ports = portCounts(got);
391
+ }
392
+ if (got.length === 0) {
393
+ obs.cycles = { done, of: CYCLES, failedAt: "start", ...(st.end ? { died: st.end } : {}) };
394
+ if (crashSignal(st.end)) obs.died = { step: i === 0 ? "load" : `cycle ${String(i + 1)}`, signal: st.end };
395
+ await stop(st);
396
+ return obs;
397
+ }
398
+ const how = await stop(st);
399
+ if (how !== "ok") {
400
+ obs.cycles = { done, of: CYCLES, failedAt: "stop", died: how };
401
+ if (crashSignal(how)) obs.died = { step: `cycle ${String(i + 1)}`, signal: how };
402
+ return obs;
403
+ }
404
+ done++;
405
+ }
406
+ obs.cycles = { done, of: CYCLES };
407
+ const base = ports() ?? [];
408
+ const st = start(CYCLES);
409
+ const plugin = await awaitPorts(base, st.name, () => st.end === undefined);
410
+ if (plugin.length === 0) {
411
+ obs.soak = { seconds: 0, alive: false, died: st.end ?? "no client" };
412
+ if (crashSignal(st.end)) obs.died = { step: "run", signal: st.end };
413
+ await stop(st);
414
+ return obs;
415
+ }
416
+ Object.assign(obs, await run(plugin, st.p.pid, () => st.end === undefined, () => st.end ?? "alive"));
417
+ if (st.end !== undefined && crashSignal(st.end)) obs.died = { step: "soak", signal: st.end };
418
+ await stop(st);
419
+ return obs;
420
+ }
421
+
422
+ rmSync(RT_DIR, { recursive: true, force: true });
423
+ mkdirSync(RT_DIR, { recursive: true });
424
+ let result;
425
+ try {
426
+ if (JACK_COMPARE === undefined) result = { installed: true, started: false, problem: "jack_compare is not installed (make -C native, or the plugin-qualify package)" };
427
+ else result = host === "jalv" ? await jalvHost() : await protocolHost();
428
+ } catch (e) {
429
+ result = { installed: true, started: false, problem: `the driver failed: ${e.message}` };
430
+ }
431
+ process.stdout.write(`${JSON.stringify(result)}\n`);
432
+ process.exit(0);