@openmixer/plugin-qualify 0.18.5 → 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.
- package/README.md +19 -730
- package/bin/plugin-qualify-sync.mjs +1 -1
- package/bin/plugin-qualify.mjs +7 -0
- package/dist/clap-qualify.d.ts +1 -1
- package/dist/compare.d.ts +311 -0
- package/dist/compare.js +502 -0
- package/dist/compare.js.map +1 -0
- package/dist/distributed-records.d.ts +0 -2
- package/dist/distributed-records.js +0 -2
- package/dist/distributed-records.js.map +1 -1
- package/dist/host-admission.d.ts +2 -2
- package/dist/host-admission.js +7 -0
- package/dist/host-admission.js.map +1 -1
- package/dist/hosting-codes.generated.d.ts +1 -0
- package/dist/hosting-codes.generated.js +1 -0
- package/dist/hosting-codes.generated.js.map +1 -1
- package/dist/hosting-suitability.d.ts +3 -1
- package/dist/hosting-suitability.js +9 -0
- package/dist/hosting-suitability.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/lv2-loader.js +6 -5
- package/dist/lv2-loader.js.map +1 -1
- package/dist/measurement/format.d.ts +15 -0
- package/dist/measurement/format.js +19 -0
- package/dist/measurement/format.js.map +1 -1
- package/dist/measurement/sweep-jsonl.js +5 -1
- package/dist/measurement/sweep-jsonl.js.map +1 -1
- package/dist/measurement-grid.js +1 -1
- package/dist/pins-file.d.ts +22 -2
- package/dist/pins-file.js +118 -17
- package/dist/pins-file.js.map +1 -1
- package/dist/qualification-record.d.ts +26 -0
- package/dist/qualification-record.js +21 -0
- package/dist/qualification-record.js.map +1 -1
- package/dist/qualify-declarations.d.ts +6 -0
- package/dist/qualify-declarations.js +8 -0
- package/dist/qualify-declarations.js.map +1 -1
- package/dist/qualify-isolated.d.ts +16 -3
- package/dist/qualify-isolated.js +31 -3
- package/dist/qualify-isolated.js.map +1 -1
- package/dist/qualify-pool.d.ts +129 -0
- package/dist/qualify-pool.js +290 -0
- package/dist/qualify-pool.js.map +1 -0
- package/dist/qualify-rejudge.js +7 -0
- package/dist/qualify-rejudge.js.map +1 -1
- package/dist/qualify-sync.d.ts +36 -1
- package/dist/qualify-sync.js +144 -20
- package/dist/qualify-sync.js.map +1 -1
- package/dist/records-package.d.ts +5 -0
- package/dist/records-package.js +25 -8
- package/dist/records-package.js.map +1 -1
- package/fixtures/clap-scan/fake.json +8 -2
- package/fixtures/probe-output/success-msi.json +2 -2
- package/package.json +3 -3
- package/src/clap-qualify.ts +1 -1
- package/src/compare.ts +699 -0
- package/src/distributed-records.ts +0 -3
- package/src/host-admission.ts +9 -2
- package/src/hosting-codes.generated.ts +1 -0
- package/src/hosting-suitability.ts +14 -0
- package/src/index.ts +1 -0
- package/src/lv2-loader.ts +5 -4
- package/src/measurement/format.ts +32 -0
- package/src/measurement/sweep-jsonl.ts +5 -1
- package/src/measurement-grid.ts +1 -1
- package/src/pins-file.ts +109 -16
- package/src/qualification-record.ts +38 -0
- package/src/qualify-declarations.ts +8 -0
- package/src/qualify-isolated.ts +31 -5
- package/src/qualify-pool.ts +318 -0
- package/src/qualify-rejudge.ts +8 -0
- package/src/qualify-sync.ts +140 -19
- package/src/records-package.ts +25 -8
- package/tools/benchmark.py +9 -3
- package/tools/compare-host.mjs +432 -0
- package/tools/compare-run.mjs +355 -0
- package/tools/descriptors.py +27 -0
- package/tools/isolated-step.mjs +3 -1
- package/tools/lilv_load.py +25 -0
- package/tools/lv2_resolve.py +3 -1
- package/tools/pool-step.mjs +186 -0
- package/tools/scan.py +16 -20
- package/tools/audit-overlap.sh +0 -250
- package/tools/commit-message-check.sh +0 -128
- package/tools/commit-message-patterns.json +0 -41
- 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
|
+
}
|
package/tools/descriptors.py
CHANGED
|
@@ -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
|
|
package/tools/isolated-step.mjs
CHANGED
|
@@ -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
|
package/tools/lv2_resolve.py
CHANGED
|
@@ -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);
|