@openmixer/plugin-qualify 0.18.0 → 0.18.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 +84 -2
- package/bin/plugin-qualify-sync.mjs +3 -1
- package/dist/distributed-records.d.ts +4 -2
- package/dist/distributed-records.js +1 -1
- package/dist/distributed-records.js.map +1 -1
- package/dist/host-admission.d.ts +2 -0
- package/dist/host-admission.js +12 -3
- package/dist/host-admission.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/probe-judge.d.ts +11 -0
- package/dist/probe-judge.js +4 -1
- package/dist/probe-judge.js.map +1 -1
- package/dist/probe-runner.js +3 -2
- package/dist/probe-runner.js.map +1 -1
- package/dist/qualification-record.d.ts +90 -8
- package/dist/qualification-record.js +18 -13
- package/dist/qualification-record.js.map +1 -1
- package/dist/qualify-adapter-pass.d.ts +67 -0
- package/dist/qualify-adapter-pass.js +255 -0
- package/dist/qualify-adapter-pass.js.map +1 -0
- package/dist/qualify-declarations.d.ts +25 -0
- package/dist/qualify-declarations.js +17 -1
- package/dist/qualify-declarations.js.map +1 -1
- package/dist/qualify-rejudge.d.ts +81 -0
- package/dist/qualify-rejudge.js +421 -0
- package/dist/qualify-rejudge.js.map +1 -0
- package/dist/qualify-sync.d.ts +72 -1
- package/dist/qualify-sync.js +176 -17
- package/dist/qualify-sync.js.map +1 -1
- package/fixtures/records-0.16.1/http___example.org_raffo@unversioned-2060c09ff626.default.probe.json +46 -0
- package/fixtures/records-0.16.1/http___example.org_raffo@unversioned-2060c09ff626.default.qualification.json +185 -0
- package/fixtures/records-0.16.1/http___lv2plug.in_plugins_eg-amp@unversioned-f046a60efd45.default.probe.json +51 -0
- package/fixtures/records-0.16.1/http___lv2plug.in_plugins_eg-amp@unversioned-f046a60efd45.default.qualification.json +232 -0
- package/fixtures/records-0.16.1/https___github.com_lucianodato_noise-repellent@unversioned-c3b5e21dd9a7.default.probe.json +51 -0
- package/fixtures/records-0.16.1/https___github.com_lucianodato_noise-repellent@unversioned-c3b5e21dd9a7.default.qualification.json +245 -0
- package/fixtures/records-0.16.1/in.lsp-plug.impulse_responses_mono@unversioned-d5235d2f5f53.custom.probe.json +52 -0
- package/fixtures/records-0.16.1/in.lsp-plug.impulse_responses_mono@unversioned-d5235d2f5f53.custom.qualification.json +145 -0
- package/fixtures/records-0.16.1/in.lsp-plug.trigger_midi_mono@unversioned-d5235d2f5f53.custom.probe.json +52 -0
- package/fixtures/records-0.16.1/in.lsp-plug.trigger_midi_mono@unversioned-d5235d2f5f53.custom.qualification.json +155 -0
- package/package.json +1 -1
- package/src/distributed-records.ts +1 -1
- package/src/host-admission.ts +13 -3
- package/src/index.ts +2 -0
- package/src/probe-judge.ts +8 -1
- package/src/probe-runner.ts +2 -2
- package/src/qualification-record.ts +100 -15
- package/src/qualify-adapter-pass.ts +278 -0
- package/src/qualify-declarations.ts +19 -1
- package/src/qualify-rejudge.ts +452 -0
- package/src/qualify-sync.ts +192 -17
- package/tools/lv2_resolve.py +49 -0
- package/tools/mod-host-admit.mjs +21 -5
package/src/qualify-sync.ts
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { spawn, spawnSync } from "node:child_process";
|
|
20
20
|
import { createHash } from "node:crypto";
|
|
21
|
-
import { closeSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, statSync,
|
|
21
|
+
import { closeSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
22
22
|
import { createConnection } from "node:net";
|
|
23
23
|
import { homedir, tmpdir } from "node:os";
|
|
24
24
|
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
@@ -32,11 +32,13 @@ import { lv2BundlesOnPath, manifestPlugins } from "./lv2-loader.js";
|
|
|
32
32
|
import { judgmentPinEntries, pinsFile, type PinEntry } from "./pins-file.js";
|
|
33
33
|
import { hostdPinFiles, type PinFilesRun } from "./hostd-pin-files.js";
|
|
34
34
|
import type { Judgment } from "./probe-judge.js";
|
|
35
|
-
import { qualifyTargets } from "./probe-runner.js";
|
|
35
|
+
import { qualifyTargets, type ProbeRun } from "./probe-runner.js";
|
|
36
36
|
import { GRID_FULL, parseGridSpec, type GridSpec } from "./measurement-grid.js";
|
|
37
|
+
import { adapterPass, type AdapterPassOptions, type AdapterPassResult } from "./qualify-adapter-pass.js";
|
|
38
|
+
import { lv2World, rejudgeState, memoryPass, type MemoryPassResult, type RejudgeResult } from "./qualify-rejudge.js";
|
|
37
39
|
import { memoryBoundOf, parseMemoryLimit, type MemoryBound } from "./memory-bound.js";
|
|
38
40
|
import { writeQualification, type NotMeasured } from "./qualification-record.js";
|
|
39
|
-
import { DESCRIPTOR_DIR_STATE_SUBDIR, DESCRIPTOR_DIR_SYSTEM, QUALIFY_QUANTA, QUALIFY_RATES } from "./qualify-declarations.js";
|
|
41
|
+
import { DESCRIPTOR_DIR_STATE_SUBDIR, DESCRIPTOR_DIR_SYSTEM, MEMORY_PASS_PROBE_NOTES, QUALIFY_QUANTA, QUALIFY_RATES } from "./qualify-declarations.js";
|
|
40
42
|
import { toolsDir } from "./tool-paths.js";
|
|
41
43
|
import { writeFileWhole } from "./whole-file.js";
|
|
42
44
|
|
|
@@ -73,6 +75,7 @@ export const SYNC_SETTINGS: readonly SyncSetting[] = [
|
|
|
73
75
|
{ key: "grid", meaning: "the cells measured: full (every rate at every block size, 16 cells), hosts (the fast option: the host's and the console tiers' rate/quantum, then 512 at every rate; also --grid-hosts), or <rate>/<quantum>,...", default: "full" },
|
|
74
76
|
{ key: "cells_per_plugin_budget", meaning: "measure at most the first n cells of the grid a plugin; 0 is every cell", default: "0" },
|
|
75
77
|
{ key: "memory_limit", meaning: "the memory bound of every run: bytes, or with K, M, G; off for none; past it a cell is probe.oom", default: "half of the RAM" },
|
|
78
|
+
{ key: "memory_notes", meaning: "the notes an instrument that declares no voice capacity holds in a --memory-only run, 1 to 128 (basis probe)", default: String(MEMORY_PASS_PROBE_NOTES) },
|
|
76
79
|
{ key: "hostd_port", meaning: "plugin-hostd's command port the new pins are sent to; 0 sends none", default: "5555" },
|
|
77
80
|
{ key: "hostd_feedback_port", meaning: "plugin-hostd's feedback port, connected before the command port; 0 is the command port's next", default: "0" },
|
|
78
81
|
{ key: "settle_s", meaning: "wait until the plugin directories have been quiet this long before a run", default: "0" },
|
|
@@ -94,6 +97,8 @@ export interface SyncSettings {
|
|
|
94
97
|
readonly cellsPerPluginBudget: number;
|
|
95
98
|
/** The memory bound of every run; `undefined` is `off`. */
|
|
96
99
|
readonly memory?: MemoryBound;
|
|
100
|
+
/** The notes an instrument with no declared capacity holds in a memory-only run. */
|
|
101
|
+
readonly memoryNotes: number;
|
|
97
102
|
readonly hostdPort: number;
|
|
98
103
|
readonly hostdFeedbackPort: number;
|
|
99
104
|
readonly settleS: number;
|
|
@@ -128,6 +133,12 @@ export interface SyncArgs {
|
|
|
128
133
|
readonly noWait: boolean;
|
|
129
134
|
/** `--refresh-pins`: refresh the pins' files from plugin-hostd and measure nothing (the package's upgrade runs it). */
|
|
130
135
|
readonly refreshPins: boolean;
|
|
136
|
+
/** `--rejudge`: the verdicts, tiers and pins derived again from the records' figures, measuring nothing. */
|
|
137
|
+
readonly rejudge: boolean;
|
|
138
|
+
/** `--memory-only`: the footprint of each recorded plugin at the Pi's cells, merged into its record. */
|
|
139
|
+
readonly memoryOnly: boolean;
|
|
140
|
+
/** `--adapter-only`: the run of each LV2 plugin under the adapter at the Pi's cells, merged into its record. */
|
|
141
|
+
readonly adapterOnly: boolean;
|
|
131
142
|
readonly help: boolean;
|
|
132
143
|
/** The settings given as flags, by key. */
|
|
133
144
|
readonly values: ReadonlyMap<string, string>;
|
|
@@ -144,6 +155,9 @@ export function parseSyncArgs(argv: readonly string[]): { ok: true; args: SyncAr
|
|
|
144
155
|
let noDistributed = false;
|
|
145
156
|
let noWait = false;
|
|
146
157
|
let refreshPinsOnly = false;
|
|
158
|
+
let rejudge = false;
|
|
159
|
+
let memoryOnly = false;
|
|
160
|
+
let adapterOnly = false;
|
|
147
161
|
let help = false;
|
|
148
162
|
for (let i = 0; i < argv.length; i++) {
|
|
149
163
|
const a = argv[i]!;
|
|
@@ -152,6 +166,9 @@ export function parseSyncArgs(argv: readonly string[]): { ok: true; args: SyncAr
|
|
|
152
166
|
else if (a === "--no-distributed") noDistributed = true;
|
|
153
167
|
else if (a === "--no-wait") noWait = true;
|
|
154
168
|
else if (a === "--refresh-pins") refreshPinsOnly = true;
|
|
169
|
+
else if (a === "--rejudge") rejudge = true;
|
|
170
|
+
else if (a === "--memory-only") memoryOnly = true;
|
|
171
|
+
else if (a === "--adapter-only") adapterOnly = true;
|
|
155
172
|
else if (a === "--help") help = true;
|
|
156
173
|
else if (a === "--grid-hosts") values.set("grid", "hosts");
|
|
157
174
|
else if (a === "--config" || byFlag.has(a)) {
|
|
@@ -161,7 +178,9 @@ export function parseSyncArgs(argv: readonly string[]): { ok: true; args: SyncAr
|
|
|
161
178
|
else values.set(byFlag.get(a)!, v);
|
|
162
179
|
} else return { ok: false, error: `unknown argument ${a}` };
|
|
163
180
|
}
|
|
164
|
-
|
|
181
|
+
if (rejudge && (memoryOnly || adapterOnly || force || refreshPinsOnly)) return { ok: false, error: "--rejudge measures nothing: it goes with none of --memory-only, --adapter-only, --force and --refresh-pins" };
|
|
182
|
+
if ([memoryOnly, adapterOnly, refreshPinsOnly].filter(Boolean).length > 1) return { ok: false, error: "--memory-only, --adapter-only and --refresh-pins are different runs: one at a time" };
|
|
183
|
+
return { ok: true, args: { ...(config === undefined ? {} : { config }), system, force, noDistributed, noWait, refreshPins: refreshPinsOnly, rejudge, memoryOnly, adapterOnly, help, values } };
|
|
165
184
|
}
|
|
166
185
|
|
|
167
186
|
export function syncHelpText(): string {
|
|
@@ -175,6 +194,9 @@ export function syncHelpText(): string {
|
|
|
175
194
|
" --no-wait a run in progress is not waited for: exit 0, and the holder runs once more at its end",
|
|
176
195
|
" --no-distributed read no distributed record: every plugin not recorded is measured",
|
|
177
196
|
" --refresh-pins only bring every pin's files up to the ones plugin-hostd names, measuring nothing",
|
|
197
|
+
" --rejudge derive every record's verdicts, tiers and pin again from the figures it holds; measures nothing",
|
|
198
|
+
" --memory-only measure only the footprint of each recorded plugin at the Pi's cells and merge it; with --force, those already merged too",
|
|
199
|
+
" --adapter-only run each LV2 plugin only under omx-clap-host's LV2 adapter at the Pi's cells, on private JACK servers, and merge it; with --force, those already done too",
|
|
178
200
|
" --grid-hosts the fast grid, the same as --grid hosts (7 cells with the shipped hosts)",
|
|
179
201
|
" --help this text",
|
|
180
202
|
...rows,
|
|
@@ -226,6 +248,8 @@ export function resolveSyncSettings(
|
|
|
226
248
|
const memoryLimit = memoryText === undefined || memoryText === "" ? undefined : parseMemoryLimit(memoryText);
|
|
227
249
|
if (memoryLimit !== undefined && !memoryLimit.ok) return { ok: false, error: `memory_limit: ${memoryLimit.error}` };
|
|
228
250
|
const memory = memoryBoundOf(memoryLimit?.bytes);
|
|
251
|
+
const memoryNotes = number("memory_notes", 1);
|
|
252
|
+
if (memoryNotes === undefined || !Number.isInteger(memoryNotes) || memoryNotes > 128) return { ok: false, error: "memory_notes must be a whole number of notes, 1 to 128" };
|
|
229
253
|
const profileId = get("host_profile") || "omx-clap-host";
|
|
230
254
|
const hostProfile = shippedHostProfile(profileId);
|
|
231
255
|
if (hostProfile === undefined) return { ok: false, error: `host_profile ${profileId} is not a shipped host profile` };
|
|
@@ -256,6 +280,7 @@ export function resolveSyncSettings(
|
|
|
256
280
|
grid: grid.spec,
|
|
257
281
|
cellsPerPluginBudget,
|
|
258
282
|
...(memory === undefined ? {} : { memory }),
|
|
283
|
+
memoryNotes,
|
|
259
284
|
hostdPort,
|
|
260
285
|
hostdFeedbackPort: feedbackPort === 0 && hostdPort !== 0 ? hostdPort + 1 : feedbackPort,
|
|
261
286
|
settleS,
|
|
@@ -279,6 +304,40 @@ export interface SyncItem {
|
|
|
279
304
|
readonly files: readonly string[];
|
|
280
305
|
/** The LV2 plugin's uri. */
|
|
281
306
|
readonly uri?: string;
|
|
307
|
+
/** The LV2 plugin's binary as lilv loads it, an absolute path that may lie outside `root` (a complement's). */
|
|
308
|
+
readonly binary?: string;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** One LV2 plugin as lilv resolves it over the whole search path: the bundle its version rule takes, and its binary. */
|
|
312
|
+
export interface Lv2Resolved {
|
|
313
|
+
readonly uri: string;
|
|
314
|
+
readonly bundle: string;
|
|
315
|
+
readonly binary?: string | null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** The bundles lilv takes over `dirs`, or why it could not say. A stand-in in tests. */
|
|
319
|
+
export type Lv2Resolve = (dirs: readonly string[]) => readonly Lv2Resolved[] | { readonly problem: string };
|
|
320
|
+
|
|
321
|
+
/** lilv itself, one world over the whole search path (tools/lv2_resolve.py), as every host built on it resolves (#199). */
|
|
322
|
+
export const LILV_RESOLVE: Lv2Resolve = (dirs) => {
|
|
323
|
+
const r = spawnSync("python3", [join(toolsDir(), "lv2_resolve.py")], {
|
|
324
|
+
encoding: "utf8", timeout: 120_000, killSignal: "SIGKILL", maxBuffer: 64 << 20, env: { ...process.env, LV2_PATH: dirs.join(":") },
|
|
325
|
+
});
|
|
326
|
+
if (r.error !== undefined || r.status !== 0) return { problem: (r.stderr ?? "").trim().split("\n").pop() || (r.error?.message ?? `exit ${String(r.status)}`) };
|
|
327
|
+
try {
|
|
328
|
+
return (JSON.parse(r.stdout) as { plugins: Lv2Resolved[] }).plugins;
|
|
329
|
+
} catch (e) {
|
|
330
|
+
return { problem: `lv2_resolve.py printed no JSON: ${(e as Error).message}` };
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
/** Whether `path` exists and is, through its links, the file `target` is. */
|
|
335
|
+
function linksTo(path: string, target: string): boolean {
|
|
336
|
+
try {
|
|
337
|
+
return realpathSync(path) === realpathSync(target);
|
|
338
|
+
} catch {
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
282
341
|
}
|
|
283
342
|
|
|
284
343
|
const CLAP_SUFFIX = ".clap";
|
|
@@ -322,9 +381,13 @@ function clapFiles(dir: string, seen: Set<string>, problems: string[]): string[]
|
|
|
322
381
|
* LV2 directories that declares it, as plugin-hostd finds it. A directory that cannot be read is a problem named, and
|
|
323
382
|
* the others are still walked.
|
|
324
383
|
*/
|
|
325
|
-
export function discoverSyncItems(
|
|
384
|
+
export function discoverSyncItems(
|
|
385
|
+
settings: Pick<SyncSettings, "lv2Dirs" | "clapDirs">,
|
|
386
|
+
lv2Resolve?: Lv2Resolve,
|
|
387
|
+
): { items: SyncItem[]; problems: string[]; notes: string[] } {
|
|
326
388
|
const items: SyncItem[] = [];
|
|
327
389
|
const problems: string[] = [];
|
|
390
|
+
const notes: string[] = [];
|
|
328
391
|
const seen = new Set<string>();
|
|
329
392
|
const clap = new Set<string>();
|
|
330
393
|
for (const dir of settings.clapDirs) {
|
|
@@ -335,6 +398,11 @@ export function discoverSyncItems(settings: Pick<SyncSettings, "lv2Dirs" | "clap
|
|
|
335
398
|
items.push({ key: `clap:${path}`, format: "clap", root: dirname(path), files: [basename(path)] });
|
|
336
399
|
}
|
|
337
400
|
}
|
|
401
|
+
// the bundle lilv takes for each plugin uri; without lilv, the first bundle found (and the log says so)
|
|
402
|
+
const resolved = lv2Resolve === undefined || settings.lv2Dirs.length === 0 ? undefined : lv2Resolve(settings.lv2Dirs);
|
|
403
|
+
const taken = new Map<string, Lv2Resolved>();
|
|
404
|
+
if (resolved !== undefined && "problem" in resolved) notes.push(`lilv could not resolve the LV2 plugins (${resolved.problem}); each is keyed by the first bundle found`);
|
|
405
|
+
else if (resolved !== undefined) for (const p of resolved) taken.set(p.uri, p);
|
|
338
406
|
const lv2 = new Set<string>();
|
|
339
407
|
for (const dir of settings.lv2Dirs) {
|
|
340
408
|
let bundles: string[];
|
|
@@ -353,13 +421,20 @@ export function discoverSyncItems(settings: Pick<SyncSettings, "lv2Dirs" | "clap
|
|
|
353
421
|
continue;
|
|
354
422
|
}
|
|
355
423
|
for (const p of manifestPlugins(ttl)) {
|
|
424
|
+
const lilv = taken.get(p.uri);
|
|
425
|
+
// lilv takes another bundle for this uri: this one is not the plugin a host loads
|
|
426
|
+
if (lilv !== undefined && resolve(lilv.bundle) !== resolve(bundle)) continue;
|
|
356
427
|
if (lv2.has(p.uri)) continue;
|
|
357
428
|
lv2.add(p.uri);
|
|
358
|
-
|
|
429
|
+
const binary = lilv?.binary ?? (p.binary === undefined ? undefined : join(bundle, p.binary));
|
|
430
|
+
const files = ["manifest.ttl", ...p.files.filter((f) => f !== "manifest.ttl")];
|
|
431
|
+
// a binary named by an absolute IRI (a complement's) is tracked through the bundle's link to it, when it has one
|
|
432
|
+
if (binary !== undefined && !files.includes(basename(binary)) && linksTo(join(bundle, basename(binary)), binary)) files.push(basename(binary));
|
|
433
|
+
items.push({ key: `lv2:${p.uri}`, format: "lv2", root: bundle, files, uri: p.uri, ...(binary === undefined ? {} : { binary }) });
|
|
359
434
|
}
|
|
360
435
|
}
|
|
361
436
|
}
|
|
362
|
-
return { items, problems };
|
|
437
|
+
return { items, problems, notes };
|
|
363
438
|
}
|
|
364
439
|
|
|
365
440
|
// --- the file set -----------------------------------------------------------------------------------------------------
|
|
@@ -428,6 +503,19 @@ export const SYNC_CODE = {
|
|
|
428
503
|
notMeasured: "qualify.sync.not-measured",
|
|
429
504
|
} as const;
|
|
430
505
|
|
|
506
|
+
/**
|
|
507
|
+
* Why a plugin is unmeasured because of the machine it ran on rather than what it is (#192): an EACCES, EPERM or
|
|
508
|
+
* ENOSPC the run hit, or a measurement that could not run or wrote no document. Such a plugin is measured again by
|
|
509
|
+
* the next run although its files are unchanged; a reason of the plugin's own (probe.limit.not-implemented, no such
|
|
510
|
+
* plugin, a crash) is not. `undefined` for every other outcome.
|
|
511
|
+
*/
|
|
512
|
+
export function machineReason(p: Pick<SyncPlugin, "outcome" | "code" | "detail">): string | undefined {
|
|
513
|
+
if (p.outcome !== "unmeasured" || p.code !== SYNC_CODE.notMeasured) return undefined;
|
|
514
|
+
const errno = /\b(EACCES|EPERM|ENOSPC)\b/.exec(p.detail ?? "")?.[1];
|
|
515
|
+
if (errno !== undefined) return errno;
|
|
516
|
+
return /wrote no document|cannot run the probe runner/.test(p.detail ?? "") ? "the measurement wrote no document" : undefined;
|
|
517
|
+
}
|
|
518
|
+
|
|
431
519
|
/** What the quick qualification never measures, stated in every record it writes. */
|
|
432
520
|
export const QUICK_NOT_MEASURED: readonly NotMeasured[] = [{ dimension: "stability", code: HOSTING_CODE.stabilityUnsoaked }];
|
|
433
521
|
|
|
@@ -495,6 +583,28 @@ export function recordedAsIs(item: SyncItem, entry: SyncEntry | undefined): { sa
|
|
|
495
583
|
return { same, files: now };
|
|
496
584
|
}
|
|
497
585
|
|
|
586
|
+
/**
|
|
587
|
+
* The entry of an LV2 plugin a host now loads from another bundle than it was qualified in, when its binary is the
|
|
588
|
+
* same bytes (#199): the entry with the new bundle and the new bundle's file digests, its verdicts and records kept.
|
|
589
|
+
* `undefined` when the item is not that case: not LV2, no entry, the same bundle, a binary that cannot be read, or one
|
|
590
|
+
* whose bytes differ from the recorded binary's (then it is measured again).
|
|
591
|
+
*/
|
|
592
|
+
export function sameBinaryElsewhere(item: SyncItem, entry: SyncEntry | undefined): SyncEntry | undefined {
|
|
593
|
+
if (item.format !== "lv2" || entry === undefined || item.binary === undefined || resolve(entry.root) === resolve(item.root)) return undefined;
|
|
594
|
+
const was = entry.files.find((f) => basename(f.path) === basename(item.binary!));
|
|
595
|
+
if (was === undefined) return undefined;
|
|
596
|
+
let now: string;
|
|
597
|
+
try {
|
|
598
|
+
now = fileSha256(item.binary);
|
|
599
|
+
} catch {
|
|
600
|
+
return undefined;
|
|
601
|
+
}
|
|
602
|
+
if (now !== was.sha256) return undefined;
|
|
603
|
+
const files = digestFiles(item.root, item.files);
|
|
604
|
+
if (files === undefined) return undefined;
|
|
605
|
+
return { ...entry, root: item.root, files };
|
|
606
|
+
}
|
|
607
|
+
|
|
498
608
|
// --- one item ---------------------------------------------------------------------------------------------------------
|
|
499
609
|
|
|
500
610
|
export interface SyncContext {
|
|
@@ -693,14 +803,10 @@ function qualifyClap(ctx: SyncContext, item: SyncItem): SyncPlugin[] {
|
|
|
693
803
|
|
|
694
804
|
/** An LV2 plugin, measured in a world of its own bundle only, as plugin-hostd runs a pinned LV2 plugin. */
|
|
695
805
|
function qualifyLv2(ctx: SyncContext, item: SyncItem): SyncPlugin[] {
|
|
696
|
-
|
|
697
|
-
try {
|
|
698
|
-
symlinkSync(item.root, join(world, basename(item.root)));
|
|
806
|
+
return lv2World(item.root, (world) => {
|
|
699
807
|
const pass = runPass(ctx, { format: "lv2", uri: item.uri!, bundle: item.root }, { ...ctx.env, LV2_PATH: world });
|
|
700
808
|
return [...pass.failures.map((f): SyncPlugin => ({ uri: item.uri!, outcome: "unmeasured", code: SYNC_CODE.notMeasured, detail: f.problem })), ...judgedPlugins(ctx, pass.runs)];
|
|
701
|
-
}
|
|
702
|
-
rmSync(world, { recursive: true, force: true });
|
|
703
|
-
}
|
|
809
|
+
});
|
|
704
810
|
}
|
|
705
811
|
|
|
706
812
|
/** One item qualified, whatever happens to it: an error of the sync's own while measuring it is its outcome, not the run's end. */
|
|
@@ -984,6 +1090,14 @@ export interface SyncSummary {
|
|
|
984
1090
|
readonly sent?: number;
|
|
985
1091
|
/** The pins whose files were refreshed from plugin-hostd without a measurement (plugin-qualify#239). */
|
|
986
1092
|
readonly refreshed?: number;
|
|
1093
|
+
/** `--rejudge`: the records whose verdicts changed, the ones that stood, and the ones that owe a measurement. */
|
|
1094
|
+
readonly rejudge?: { readonly changed: number; readonly unchanged: number; readonly owing: number };
|
|
1095
|
+
/** `--memory-only`: the plugins measured, those whose footprints were already merged, and those no run could read. */
|
|
1096
|
+
readonly memoryOnly?: { readonly measured: number; readonly already: number; readonly failed: number };
|
|
1097
|
+
/** Every plugin the run measured was unmeasured for this one reason of the machine ({@link machineReason}): the run failed. */
|
|
1098
|
+
readonly machineFailure?: string;
|
|
1099
|
+
/** `--adapter-only`: the plugins run, those done already at these host versions, and those whose host could not be run. */
|
|
1100
|
+
readonly adapterOnly?: { readonly measured: number; readonly already: number; readonly failed: number };
|
|
987
1101
|
}
|
|
988
1102
|
|
|
989
1103
|
/**
|
|
@@ -1020,7 +1134,7 @@ export function descriptorAccessProblems(dir: string): string[] {
|
|
|
1020
1134
|
}
|
|
1021
1135
|
|
|
1022
1136
|
export function summaryLine(s: SyncSummary): string {
|
|
1023
|
-
return `qualified ${String(s.qualified)}, skipped ${String(s.skipped)}, refused ${String(s.refused)}, crashed ${String(s.crashed)}, hung ${String(s.hung)}, unmeasured ${String(s.unmeasured)}, distributed ${String(s.distributed)}${s.refreshed ? `, pins refreshed ${String(s.refreshed)}` : ""}, ${s.seconds.toFixed(1)} s`;
|
|
1137
|
+
return `qualified ${String(s.qualified)}, skipped ${String(s.skipped)}, refused ${String(s.refused)}, crashed ${String(s.crashed)}, hung ${String(s.hung)}, unmeasured ${String(s.unmeasured)}, distributed ${String(s.distributed)}${s.refreshed ? `, pins refreshed ${String(s.refreshed)}` : ""}${s.rejudge === undefined ? "" : `, rejudged ${String(s.rejudge.changed)}, unchanged ${String(s.rejudge.unchanged)}, owing ${String(s.rejudge.owing)}`}${s.memoryOnly === undefined ? "" : `, memory measured ${String(s.memoryOnly.measured)}, already ${String(s.memoryOnly.already)}, failed ${String(s.memoryOnly.failed)}, leaks not measured`}${s.adapterOnly === undefined ? "" : `, adapter run ${String(s.adapterOnly.measured)}, already ${String(s.adapterOnly.already)}, failed ${String(s.adapterOnly.failed)}`}, ${s.seconds.toFixed(1)} s`;
|
|
1024
1138
|
}
|
|
1025
1139
|
|
|
1026
1140
|
/** The exit status `flock -n` returns when the lock is held (-E), told apart from a failure of flock itself. */
|
|
@@ -1066,8 +1180,20 @@ export interface SyncRunOptions {
|
|
|
1066
1180
|
readonly force?: boolean;
|
|
1067
1181
|
/** `--refresh-pins`: only refresh the pins' files from plugin-hostd and send the changed ones; measure nothing. */
|
|
1068
1182
|
readonly refreshOnly?: boolean;
|
|
1183
|
+
/** `--rejudge`: derive the verdicts again from the records' figures; measure nothing. */
|
|
1184
|
+
readonly rejudge?: boolean;
|
|
1185
|
+
/** `--memory-only`: take only the footprints; `force` takes those already merged again. */
|
|
1186
|
+
readonly memoryOnly?: boolean;
|
|
1187
|
+
/** `--adapter-only`: take only the adapter runs; `force` takes those already done again. */
|
|
1188
|
+
readonly adapterOnly?: boolean;
|
|
1189
|
+
/** Stand-ins for the pass's hosts, in tests. */
|
|
1190
|
+
readonly adapterPass?: AdapterPassOptions;
|
|
1069
1191
|
/** The run of `plugin-hostd --pin-files`, a stand-in in tests. */
|
|
1070
1192
|
readonly pinFilesRun?: PinFilesRun;
|
|
1193
|
+
/** The run of the probe runner in a memory-only pass, a stand-in in tests. */
|
|
1194
|
+
readonly probeRun?: ProbeRun;
|
|
1195
|
+
/** How LV2 plugins are resolved to the bundle a host loads: lilv itself unless a test stands in (#199). */
|
|
1196
|
+
readonly lv2Resolve?: Lv2Resolve;
|
|
1071
1197
|
/** False under `--no-wait`: a run in progress is not waited for; the holder runs once more at its end instead. */
|
|
1072
1198
|
readonly wait?: boolean;
|
|
1073
1199
|
readonly env?: Readonly<Record<string, string | undefined>>;
|
|
@@ -1123,6 +1249,9 @@ function addSummaries(a: SyncSummary, b: SyncSummary): SyncSummary {
|
|
|
1123
1249
|
seconds: a.seconds + b.seconds,
|
|
1124
1250
|
...(sent === 0 ? {} : { sent }),
|
|
1125
1251
|
...(refreshed === 0 ? {} : { refreshed }),
|
|
1252
|
+
...(b.rejudge === undefined ? (a.rejudge === undefined ? {} : { rejudge: a.rejudge }) : { rejudge: b.rejudge }),
|
|
1253
|
+
...(b.memoryOnly === undefined ? (a.memoryOnly === undefined ? {} : { memoryOnly: a.memoryOnly }) : { memoryOnly: b.memoryOnly }),
|
|
1254
|
+
...(b.adapterOnly === undefined ? (a.adapterOnly === undefined ? {} : { adapterOnly: a.adapterOnly }) : { adapterOnly: b.adapterOnly }),
|
|
1126
1255
|
};
|
|
1127
1256
|
}
|
|
1128
1257
|
|
|
@@ -1143,7 +1272,7 @@ function scannedBy(settings: Pick<SyncSettings, "lv2Dirs" | "clapDirs">, entry:
|
|
|
1143
1272
|
async function runLocked(settings: SyncSettings, options: SyncRunOptions): Promise<SyncSummary> {
|
|
1144
1273
|
const log = options.log ?? (() => {});
|
|
1145
1274
|
const env = options.env ?? process.env;
|
|
1146
|
-
log(options.refreshOnly === true ? "pin refresh begins" : "sync run begins");
|
|
1275
|
+
log(options.rejudge === true ? "re-judge begins: nothing is measured" : options.memoryOnly === true ? "memory-only pass begins" : options.adapterOnly === true ? "adapter-only pass begins" : options.refreshOnly === true ? "pin refresh begins" : "sync run begins");
|
|
1147
1276
|
{
|
|
1148
1277
|
const started = Date.now();
|
|
1149
1278
|
const dirs = [...settings.clapDirs, ...settings.lv2Dirs];
|
|
@@ -1155,6 +1284,23 @@ async function runLocked(settings: SyncSettings, options: SyncRunOptions): Promi
|
|
|
1155
1284
|
writeFileWhole(settings.stateFile, `${JSON.stringify(fresh.state, null, 1)}\n`);
|
|
1156
1285
|
}
|
|
1157
1286
|
if (options.refreshOnly === true) return sendRefreshed(settings, fresh.state, fresh.refreshed.length, started, log);
|
|
1287
|
+
if (options.rejudge === true) {
|
|
1288
|
+
const r: RejudgeResult = rejudgeState(ctx, fresh.state);
|
|
1289
|
+
if (JSON.stringify(r.state) !== JSON.stringify(fresh.state)) writeFileWhole(settings.stateFile, `${JSON.stringify(r.state, null, 1)}\n`);
|
|
1290
|
+
const summary = await sendRefreshed(settings, r.state, fresh.refreshed.length, started, log);
|
|
1291
|
+
return { ...summary, rejudge: { changed: r.changed, unchanged: r.unchanged, owing: r.owing.length } };
|
|
1292
|
+
}
|
|
1293
|
+
if (options.adapterOnly === true) {
|
|
1294
|
+
const r: AdapterPassResult = adapterPass(ctx, fresh.state, { ...options.adapterPass, force: options.force === true });
|
|
1295
|
+
log(`adapter runs under ${Object.entries(r.versions).map(([k, v]) => `${k} ${v}`).join(", ")}`);
|
|
1296
|
+
const summary = await sendRefreshed(settings, fresh.state, fresh.refreshed.length, started, log);
|
|
1297
|
+
return { ...summary, adapterOnly: { measured: r.measured, already: r.already, failed: r.failed } };
|
|
1298
|
+
}
|
|
1299
|
+
if (options.memoryOnly === true) {
|
|
1300
|
+
const r: MemoryPassResult = memoryPass(ctx, fresh.state, { force: options.force === true, ...(options.probeRun === undefined ? {} : { run: options.probeRun }) });
|
|
1301
|
+
const summary = await sendRefreshed(settings, fresh.state, fresh.refreshed.length, started, log);
|
|
1302
|
+
return { ...summary, memoryOnly: { measured: r.measured, already: r.already, failed: r.failed } };
|
|
1303
|
+
}
|
|
1158
1304
|
await waitQuiet(dirs, settings.settleS, log);
|
|
1159
1305
|
const count = { qualified: 0, skipped: 0, refused: 0, crashed: 0, hung: 0, unmeasured: 0, distributed: 0 };
|
|
1160
1306
|
const layer = distributedLayerOf(settings, options.force === true, log);
|
|
@@ -1168,11 +1314,15 @@ async function runLocked(settings: SyncSettings, options: SyncRunOptions): Promi
|
|
|
1168
1314
|
const stateBefore = state;
|
|
1169
1315
|
let unreadable = false;
|
|
1170
1316
|
const lv2Pinned: string[] = [];
|
|
1317
|
+
// the plugins measured this run, and how many of them the machine left unmeasured, by reason (#192)
|
|
1318
|
+
let measuredHere = 0;
|
|
1319
|
+
const byMachine = new Map<string, number>();
|
|
1171
1320
|
// a change made while a pass ran is caught by one more pass, a few at most
|
|
1172
1321
|
for (let pass = 0; pass < 3; pass++) {
|
|
1173
1322
|
const signature = directoriesSignature(dirs);
|
|
1174
|
-
const { items, problems } = discoverSyncItems(settings);
|
|
1323
|
+
const { items, problems, notes } = discoverSyncItems(settings, options.lv2Resolve ?? LILV_RESOLVE);
|
|
1175
1324
|
for (const p of problems) log(p);
|
|
1325
|
+
if (pass === 0) for (const n of notes) log(n);
|
|
1176
1326
|
unreadable = problems.length > 0;
|
|
1177
1327
|
const found = new Set(items.map((i) => i.key));
|
|
1178
1328
|
const entries: Record<string, SyncEntry> = {};
|
|
@@ -1186,10 +1336,25 @@ async function runLocked(settings: SyncSettings, options: SyncRunOptions): Promi
|
|
|
1186
1336
|
}
|
|
1187
1337
|
for (const item of items) {
|
|
1188
1338
|
const recorded = recordedAsIs(item, entries[item.key]);
|
|
1189
|
-
|
|
1339
|
+
const retry = recorded.same ? entries[item.key]!.plugins.map(machineReason).find((r) => r !== undefined) : undefined;
|
|
1340
|
+
if (recorded.same && options.force !== true && retry === undefined) {
|
|
1190
1341
|
if (pass === 0) count.skipped += entries[item.key]!.plugins.length;
|
|
1191
1342
|
continue;
|
|
1192
1343
|
}
|
|
1344
|
+
// an LV2 plugin a host now loads from another bundle with the same binary (a complement that restates its TTL,
|
|
1345
|
+
// #199): the record stands, keyed by its uri and its binary; only the bundle, its files and the pins' files move
|
|
1346
|
+
const moved = options.force === true ? undefined : sameBinaryElsewhere(item, entries[item.key]);
|
|
1347
|
+
if (moved !== undefined) {
|
|
1348
|
+
entries[item.key] = { ...moved, plugins: moved.plugins.map((p) => ({ ...p, ...(p.pin === undefined ? {} : { pin: withHostdFiles(ctx, p.pin) }) })) };
|
|
1349
|
+
if (item.format === "lv2") lv2Pinned.push(...entries[item.key]!.plugins.filter((p) => p.pin !== undefined).map((p) => p.uri));
|
|
1350
|
+
if (pass === 0) count.skipped += moved.plugins.length;
|
|
1351
|
+
log(`${item.key}: loaded from ${item.root} now, with the binary it was qualified with; its record stands and its pin's files follow the bundle`);
|
|
1352
|
+
state = { schema: SYNC_STATE_SCHEMA, entries };
|
|
1353
|
+
writeFileWhole(settings.stateFile, `${JSON.stringify(state, null, 1)}\n`);
|
|
1354
|
+
writeFileWhole(settings.pinFile, syncPinsText(state));
|
|
1355
|
+
continue;
|
|
1356
|
+
}
|
|
1357
|
+
if (recorded.same && retry !== undefined) log(`${item.key}: unmeasured last run by the machine (${retry}); measured again`);
|
|
1193
1358
|
const files = recorded.files ?? digestFiles(item.root, item.files);
|
|
1194
1359
|
if (files === undefined) {
|
|
1195
1360
|
log(`${item.key}: a file of it cannot be read; skipped this run`);
|
|
@@ -1211,6 +1376,11 @@ async function runLocked(settings: SyncSettings, options: SyncRunOptions): Promi
|
|
|
1211
1376
|
if (item.format === "lv2") lv2Pinned.push(...entry.plugins.filter((p) => p.pin !== undefined).map((p) => p.uri));
|
|
1212
1377
|
for (const p of entry.plugins) {
|
|
1213
1378
|
count[p.outcome]++;
|
|
1379
|
+
measuredHere++;
|
|
1380
|
+
// a reason of the machine is said once: the plugins after the first are counted, not listed
|
|
1381
|
+
const why = machineReason(p);
|
|
1382
|
+
if (why !== undefined) byMachine.set(why, (byMachine.get(why) ?? 0) + 1);
|
|
1383
|
+
if (why !== undefined && byMachine.get(why)! > 1) continue;
|
|
1214
1384
|
log(`${p.uri}: ${p.outcome}${p.code === undefined ? "" : ` (${p.code})`}${p.detail === undefined ? "" : `: ${p.detail}`}`);
|
|
1215
1385
|
}
|
|
1216
1386
|
state = { schema: SYNC_STATE_SCHEMA, entries };
|
|
@@ -1244,8 +1414,13 @@ async function runLocked(settings: SyncSettings, options: SyncRunOptions): Promi
|
|
|
1244
1414
|
const replies = "replies" in sent ? sent.replies : [];
|
|
1245
1415
|
for (let i = 0; i < replies.length; i++) if (replies[i] !== "resp 0") log(`plugin-hostd answered ${replies[i]!} to ${changed[i]!.split(" ")[1]!}`);
|
|
1246
1416
|
const taken = replies.filter((r) => r === "resp 0").length;
|
|
1417
|
+
for (const [why, n] of byMachine) if (n > 1) log(`${String(n)} plugins unmeasured by the machine (${why}); the first is listed above`);
|
|
1418
|
+
const [only] = byMachine.size === 1 ? [...byMachine] : [];
|
|
1419
|
+
const machineFailure = only !== undefined && only[1] === measuredHere ? only[0] : undefined;
|
|
1420
|
+
if (machineFailure !== undefined) log(`every plugin this run measured was left unmeasured by the machine (${machineFailure}): the run failed`);
|
|
1247
1421
|
return {
|
|
1248
1422
|
...count,
|
|
1423
|
+
...(machineFailure === undefined ? {} : { machineFailure }),
|
|
1249
1424
|
seconds: (Date.now() - started) / 1000,
|
|
1250
1425
|
...(taken === 0 ? {} : { sent: taken }),
|
|
1251
1426
|
...(fresh.refreshed.length === 0 ? {} : { refreshed: fresh.refreshed.length }),
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
3
|
+
# Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
|
|
4
|
+
"""Which bundle lilv takes for each LV2 plugin on the search path, and its binary (plugin-qualify#199).
|
|
5
|
+
|
|
6
|
+
lilv loads one world over the whole LV2_PATH; where two bundles describe one plugin URI, its version rule decides
|
|
7
|
+
(the higher lv2:minorVersion, then lv2:microVersion, replaces the other). An LV2 complement writes a replacing bundle
|
|
8
|
+
with the microVersion one above, so lilv, and every host built on it, loads that one. The sync keys an LV2 plugin by
|
|
9
|
+
the bundle lilv takes, never by the first one a directory listing finds.
|
|
10
|
+
|
|
11
|
+
LV2_PATH=<dir>:<dir>... lv2_resolve.py
|
|
12
|
+
|
|
13
|
+
prints one JSON object: {"plugins": [{"uri", "bundle", "binary"}, ...]}, `bundle` a directory, `binary` the absolute
|
|
14
|
+
path of the library lilv would load (it may lie outside the bundle).
|
|
15
|
+
"""
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
from urllib.parse import unquote, urlparse
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def path_of(uri):
|
|
22
|
+
"""A file: URI as a path, its trailing slash dropped."""
|
|
23
|
+
return unquote(urlparse(uri).path).rstrip("/")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def main():
|
|
27
|
+
try:
|
|
28
|
+
import lilv
|
|
29
|
+
except ImportError as e:
|
|
30
|
+
print(f"lv2_resolve: lilv's Python bindings are not installed: {e}", file=sys.stderr)
|
|
31
|
+
return 2
|
|
32
|
+
world = lilv.World()
|
|
33
|
+
world.load_all()
|
|
34
|
+
out = []
|
|
35
|
+
for plugin in world.get_all_plugins():
|
|
36
|
+
library = plugin.get_library_uri()
|
|
37
|
+
out.append({
|
|
38
|
+
"uri": str(plugin.get_uri()),
|
|
39
|
+
"bundle": path_of(str(plugin.get_bundle_uri())),
|
|
40
|
+
"binary": path_of(str(library)) if library is not None else None,
|
|
41
|
+
})
|
|
42
|
+
out.sort(key=lambda p: p["uri"])
|
|
43
|
+
json.dump({"plugins": out}, sys.stdout)
|
|
44
|
+
sys.stdout.write("\n")
|
|
45
|
+
return 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
if __name__ == "__main__":
|
|
49
|
+
sys.exit(main())
|
package/tools/mod-host-admit.mjs
CHANGED
|
@@ -11,16 +11,28 @@ const uri = process.argv[2];
|
|
|
11
11
|
// the host to admit under: mod-host, or omx-clap-host for plugin-hostd's LV2 adapter, which answers the same protocol
|
|
12
12
|
const bin = process.argv[3] ?? "mod-host";
|
|
13
13
|
const port = 20000 + (process.pid % 20000);
|
|
14
|
-
|
|
14
|
+
// an adapter-only pass gives the host its budget for a command, an address space and a state directory of the plugin's own
|
|
15
|
+
const STEP_MS = Number(process.env.OMX_ADMIT_STEP_MS ?? 0) || 10000;
|
|
16
|
+
const ADDRESS_KB = process.env.OMX_ADMIT_ADDRESS_KB;
|
|
17
|
+
const STATE_DIR = process.env.OMX_ADMIT_STATE_DIR;
|
|
15
18
|
const INSTANCE = 0;
|
|
16
19
|
const COMMANDS = [
|
|
17
20
|
["add", `add ${uri} ${INSTANCE}`],
|
|
18
21
|
["remove", `remove ${INSTANCE}`],
|
|
19
22
|
];
|
|
20
23
|
|
|
21
|
-
const
|
|
24
|
+
const args = ["-n", "-p", String(port), ...(STATE_DIR === undefined ? [] : ["-s", STATE_DIR])];
|
|
25
|
+
// in a process group of its own, so that the end below takes whatever a plugin started as well
|
|
26
|
+
const host =
|
|
27
|
+
ADDRESS_KB === undefined
|
|
28
|
+
? spawn(bin, args, { stdio: "ignore", detached: true })
|
|
29
|
+
: spawn("/bin/sh", ["-c", `command -v "$1" >/dev/null || exit 127; ulimit -v ${ADDRESS_KB} && exec "$@"`, "sh", bin, ...args], { stdio: "ignore", detached: true });
|
|
22
30
|
let hostEnd; // how the host went, once it has
|
|
23
|
-
|
|
31
|
+
let shellNotFound = false;
|
|
32
|
+
host.on("exit", (code, signal) => {
|
|
33
|
+
hostEnd = signal ?? "exit";
|
|
34
|
+
if (code === 127) shellNotFound = true;
|
|
35
|
+
});
|
|
24
36
|
const spawnFailed = new Promise((resolve) => host.on("error", (e) => resolve(e.code)));
|
|
25
37
|
|
|
26
38
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
@@ -38,7 +50,7 @@ async function connect() {
|
|
|
38
50
|
}
|
|
39
51
|
|
|
40
52
|
async function main() {
|
|
41
|
-
if ((await Promise.race([spawnFailed, sleep(200).then(() => undefined)])) === "ENOENT") return { installed: false, steps: [] };
|
|
53
|
+
if ((await Promise.race([spawnFailed, sleep(200).then(() => undefined)])) === "ENOENT" || shellNotFound) return { installed: false, steps: [] };
|
|
42
54
|
const sock = await connect();
|
|
43
55
|
// the host never listened (omx-clap-host with no JACK server): the plugin was never loaded
|
|
44
56
|
if (sock === undefined) return { installed: true, started: false, steps: [] };
|
|
@@ -72,6 +84,10 @@ async function main() {
|
|
|
72
84
|
}
|
|
73
85
|
|
|
74
86
|
const result = await main();
|
|
75
|
-
|
|
87
|
+
try {
|
|
88
|
+
process.kill(-host.pid, "SIGKILL");
|
|
89
|
+
} catch {
|
|
90
|
+
// the host went on its own
|
|
91
|
+
}
|
|
76
92
|
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
77
93
|
process.exit(0);
|