@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.
Files changed (54) hide show
  1. package/README.md +84 -2
  2. package/bin/plugin-qualify-sync.mjs +3 -1
  3. package/dist/distributed-records.d.ts +4 -2
  4. package/dist/distributed-records.js +1 -1
  5. package/dist/distributed-records.js.map +1 -1
  6. package/dist/host-admission.d.ts +2 -0
  7. package/dist/host-admission.js +12 -3
  8. package/dist/host-admission.js.map +1 -1
  9. package/dist/index.d.ts +2 -0
  10. package/dist/index.js +2 -0
  11. package/dist/index.js.map +1 -1
  12. package/dist/probe-judge.d.ts +11 -0
  13. package/dist/probe-judge.js +4 -1
  14. package/dist/probe-judge.js.map +1 -1
  15. package/dist/probe-runner.js +3 -2
  16. package/dist/probe-runner.js.map +1 -1
  17. package/dist/qualification-record.d.ts +90 -8
  18. package/dist/qualification-record.js +18 -13
  19. package/dist/qualification-record.js.map +1 -1
  20. package/dist/qualify-adapter-pass.d.ts +67 -0
  21. package/dist/qualify-adapter-pass.js +255 -0
  22. package/dist/qualify-adapter-pass.js.map +1 -0
  23. package/dist/qualify-declarations.d.ts +25 -0
  24. package/dist/qualify-declarations.js +17 -1
  25. package/dist/qualify-declarations.js.map +1 -1
  26. package/dist/qualify-rejudge.d.ts +81 -0
  27. package/dist/qualify-rejudge.js +421 -0
  28. package/dist/qualify-rejudge.js.map +1 -0
  29. package/dist/qualify-sync.d.ts +72 -1
  30. package/dist/qualify-sync.js +176 -17
  31. package/dist/qualify-sync.js.map +1 -1
  32. package/fixtures/records-0.16.1/http___example.org_raffo@unversioned-2060c09ff626.default.probe.json +46 -0
  33. package/fixtures/records-0.16.1/http___example.org_raffo@unversioned-2060c09ff626.default.qualification.json +185 -0
  34. package/fixtures/records-0.16.1/http___lv2plug.in_plugins_eg-amp@unversioned-f046a60efd45.default.probe.json +51 -0
  35. package/fixtures/records-0.16.1/http___lv2plug.in_plugins_eg-amp@unversioned-f046a60efd45.default.qualification.json +232 -0
  36. package/fixtures/records-0.16.1/https___github.com_lucianodato_noise-repellent@unversioned-c3b5e21dd9a7.default.probe.json +51 -0
  37. package/fixtures/records-0.16.1/https___github.com_lucianodato_noise-repellent@unversioned-c3b5e21dd9a7.default.qualification.json +245 -0
  38. package/fixtures/records-0.16.1/in.lsp-plug.impulse_responses_mono@unversioned-d5235d2f5f53.custom.probe.json +52 -0
  39. package/fixtures/records-0.16.1/in.lsp-plug.impulse_responses_mono@unversioned-d5235d2f5f53.custom.qualification.json +145 -0
  40. package/fixtures/records-0.16.1/in.lsp-plug.trigger_midi_mono@unversioned-d5235d2f5f53.custom.probe.json +52 -0
  41. package/fixtures/records-0.16.1/in.lsp-plug.trigger_midi_mono@unversioned-d5235d2f5f53.custom.qualification.json +155 -0
  42. package/package.json +1 -1
  43. package/src/distributed-records.ts +1 -1
  44. package/src/host-admission.ts +13 -3
  45. package/src/index.ts +2 -0
  46. package/src/probe-judge.ts +8 -1
  47. package/src/probe-runner.ts +2 -2
  48. package/src/qualification-record.ts +100 -15
  49. package/src/qualify-adapter-pass.ts +278 -0
  50. package/src/qualify-declarations.ts +19 -1
  51. package/src/qualify-rejudge.ts +452 -0
  52. package/src/qualify-sync.ts +192 -17
  53. package/tools/lv2_resolve.py +49 -0
  54. package/tools/mod-host-admit.mjs +21 -5
@@ -1,7 +1,9 @@
1
1
  import { type HostProfile } from "./host-profiles.js";
2
2
  import { type PinEntry } from "./pins-file.js";
3
3
  import { type PinFilesRun } from "./hostd-pin-files.js";
4
+ import { type ProbeRun } from "./probe-runner.js";
4
5
  import { type GridSpec } from "./measurement-grid.js";
6
+ import { type AdapterPassOptions } from "./qualify-adapter-pass.js";
5
7
  import { type MemoryBound } from "./memory-bound.js";
6
8
  import { type NotMeasured } from "./qualification-record.js";
7
9
  /** The settings file a system service reads, `key value` per line. */
@@ -32,6 +34,8 @@ export interface SyncSettings {
32
34
  readonly cellsPerPluginBudget: number;
33
35
  /** The memory bound of every run; `undefined` is `off`. */
34
36
  readonly memory?: MemoryBound;
37
+ /** The notes an instrument with no declared capacity holds in a memory-only run. */
38
+ readonly memoryNotes: number;
35
39
  readonly hostdPort: number;
36
40
  readonly hostdFeedbackPort: number;
37
41
  readonly settleS: number;
@@ -57,6 +61,12 @@ export interface SyncArgs {
57
61
  readonly noWait: boolean;
58
62
  /** `--refresh-pins`: refresh the pins' files from plugin-hostd and measure nothing (the package's upgrade runs it). */
59
63
  readonly refreshPins: boolean;
64
+ /** `--rejudge`: the verdicts, tiers and pins derived again from the records' figures, measuring nothing. */
65
+ readonly rejudge: boolean;
66
+ /** `--memory-only`: the footprint of each recorded plugin at the Pi's cells, merged into its record. */
67
+ readonly memoryOnly: boolean;
68
+ /** `--adapter-only`: the run of each LV2 plugin under the adapter at the Pi's cells, merged into its record. */
69
+ readonly adapterOnly: boolean;
60
70
  readonly help: boolean;
61
71
  /** The settings given as flags, by key. */
62
72
  readonly values: ReadonlyMap<string, string>;
@@ -89,15 +99,30 @@ export interface SyncItem {
89
99
  readonly files: readonly string[];
90
100
  /** The LV2 plugin's uri. */
91
101
  readonly uri?: string;
102
+ /** The LV2 plugin's binary as lilv loads it, an absolute path that may lie outside `root` (a complement's). */
103
+ readonly binary?: string;
92
104
  }
105
+ /** One LV2 plugin as lilv resolves it over the whole search path: the bundle its version rule takes, and its binary. */
106
+ export interface Lv2Resolved {
107
+ readonly uri: string;
108
+ readonly bundle: string;
109
+ readonly binary?: string | null;
110
+ }
111
+ /** The bundles lilv takes over `dirs`, or why it could not say. A stand-in in tests. */
112
+ export type Lv2Resolve = (dirs: readonly string[]) => readonly Lv2Resolved[] | {
113
+ readonly problem: string;
114
+ };
115
+ /** lilv itself, one world over the whole search path (tools/lv2_resolve.py), as every host built on it resolves (#199). */
116
+ export declare const LILV_RESOLVE: Lv2Resolve;
93
117
  /**
94
118
  * Every item on the search paths. A `.clap` is one item; an LV2 plugin is one item, found in the first bundle on the
95
119
  * LV2 directories that declares it, as plugin-hostd finds it. A directory that cannot be read is a problem named, and
96
120
  * the others are still walked.
97
121
  */
98
- export declare function discoverSyncItems(settings: Pick<SyncSettings, "lv2Dirs" | "clapDirs">): {
122
+ export declare function discoverSyncItems(settings: Pick<SyncSettings, "lv2Dirs" | "clapDirs">, lv2Resolve?: Lv2Resolve): {
99
123
  items: SyncItem[];
100
124
  problems: string[];
125
+ notes: string[];
101
126
  };
102
127
  /** One file of a set: its SHA-256, as plugin-hostd's `pin.h` digests it, and the stat it was taken at. */
103
128
  export interface FileDigest {
@@ -130,6 +155,13 @@ export declare const SYNC_CODE: {
130
155
  /** The probe runner wrote no document to judge. */
131
156
  readonly notMeasured: "qualify.sync.not-measured";
132
157
  };
158
+ /**
159
+ * Why a plugin is unmeasured because of the machine it ran on rather than what it is (#192): an EACCES, EPERM or
160
+ * ENOSPC the run hit, or a measurement that could not run or wrote no document. Such a plugin is measured again by
161
+ * the next run although its files are unchanged; a reason of the plugin's own (probe.limit.not-implemented, no such
162
+ * plugin, a crash) is not. `undefined` for every other outcome.
163
+ */
164
+ export declare function machineReason(p: Pick<SyncPlugin, "outcome" | "code" | "detail">): string | undefined;
133
165
  /** What the quick qualification never measures, stated in every record it writes. */
134
166
  export declare const QUICK_NOT_MEASURED: readonly NotMeasured[];
135
167
  export interface SyncPlugin {
@@ -168,6 +200,13 @@ export declare function recordedAsIs(item: SyncItem, entry: SyncEntry | undefine
168
200
  same: boolean;
169
201
  files?: FileDigest[];
170
202
  };
203
+ /**
204
+ * The entry of an LV2 plugin a host now loads from another bundle than it was qualified in, when its binary is the
205
+ * same bytes (#199): the entry with the new bundle and the new bundle's file digests, its verdicts and records kept.
206
+ * `undefined` when the item is not that case: not LV2, no entry, the same bundle, a binary that cannot be read, or one
207
+ * whose bytes differ from the recorded binary's (then it is measured again).
208
+ */
209
+ export declare function sameBinaryElsewhere(item: SyncItem, entry: SyncEntry | undefined): SyncEntry | undefined;
171
210
  export interface SyncContext {
172
211
  readonly settings: SyncSettings;
173
212
  readonly env: Readonly<Record<string, string | undefined>>;
@@ -241,6 +280,26 @@ export interface SyncSummary {
241
280
  readonly sent?: number;
242
281
  /** The pins whose files were refreshed from plugin-hostd without a measurement (plugin-qualify#239). */
243
282
  readonly refreshed?: number;
283
+ /** `--rejudge`: the records whose verdicts changed, the ones that stood, and the ones that owe a measurement. */
284
+ readonly rejudge?: {
285
+ readonly changed: number;
286
+ readonly unchanged: number;
287
+ readonly owing: number;
288
+ };
289
+ /** `--memory-only`: the plugins measured, those whose footprints were already merged, and those no run could read. */
290
+ readonly memoryOnly?: {
291
+ readonly measured: number;
292
+ readonly already: number;
293
+ readonly failed: number;
294
+ };
295
+ /** Every plugin the run measured was unmeasured for this one reason of the machine ({@link machineReason}): the run failed. */
296
+ readonly machineFailure?: string;
297
+ /** `--adapter-only`: the plugins run, those done already at these host versions, and those whose host could not be run. */
298
+ readonly adapterOnly?: {
299
+ readonly measured: number;
300
+ readonly already: number;
301
+ readonly failed: number;
302
+ };
244
303
  }
245
304
  /**
246
305
  * Why a reader that is not the sync's user (the console, #221) cannot read the descriptors: the directory or one of its
@@ -263,8 +322,20 @@ export interface SyncRunOptions {
263
322
  readonly force?: boolean;
264
323
  /** `--refresh-pins`: only refresh the pins' files from plugin-hostd and send the changed ones; measure nothing. */
265
324
  readonly refreshOnly?: boolean;
325
+ /** `--rejudge`: derive the verdicts again from the records' figures; measure nothing. */
326
+ readonly rejudge?: boolean;
327
+ /** `--memory-only`: take only the footprints; `force` takes those already merged again. */
328
+ readonly memoryOnly?: boolean;
329
+ /** `--adapter-only`: take only the adapter runs; `force` takes those already done again. */
330
+ readonly adapterOnly?: boolean;
331
+ /** Stand-ins for the pass's hosts, in tests. */
332
+ readonly adapterPass?: AdapterPassOptions;
266
333
  /** The run of `plugin-hostd --pin-files`, a stand-in in tests. */
267
334
  readonly pinFilesRun?: PinFilesRun;
335
+ /** The run of the probe runner in a memory-only pass, a stand-in in tests. */
336
+ readonly probeRun?: ProbeRun;
337
+ /** How LV2 plugins are resolved to the bundle a host loads: lilv itself unless a test stands in (#199). */
338
+ readonly lv2Resolve?: Lv2Resolve;
268
339
  /** False under `--no-wait`: a run in progress is not waited for; the holder runs once more at its end instead. */
269
340
  readonly wait?: boolean;
270
341
  readonly env?: Readonly<Record<string, string | undefined>>;
@@ -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, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
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,9 +32,11 @@ import { judgmentPinEntries, pinsFile } from "./pins-file.js";
32
32
  import { hostdPinFiles } from "./hostd-pin-files.js";
33
33
  import { qualifyTargets } from "./probe-runner.js";
34
34
  import { GRID_FULL, parseGridSpec } from "./measurement-grid.js";
35
+ import { adapterPass } from "./qualify-adapter-pass.js";
36
+ import { lv2World, rejudgeState, memoryPass } from "./qualify-rejudge.js";
35
37
  import { memoryBoundOf, parseMemoryLimit } from "./memory-bound.js";
36
38
  import { writeQualification } from "./qualification-record.js";
37
- import { DESCRIPTOR_DIR_STATE_SUBDIR, DESCRIPTOR_DIR_SYSTEM, QUALIFY_QUANTA, QUALIFY_RATES } from "./qualify-declarations.js";
39
+ import { DESCRIPTOR_DIR_STATE_SUBDIR, DESCRIPTOR_DIR_SYSTEM, MEMORY_PASS_PROBE_NOTES, QUALIFY_QUANTA, QUALIFY_RATES } from "./qualify-declarations.js";
38
40
  import { toolsDir } from "./tool-paths.js";
39
41
  import { writeFileWhole } from "./whole-file.js";
40
42
  // --- the settings -----------------------------------------------------------------------------------------------------
@@ -59,6 +61,7 @@ export const SYNC_SETTINGS = [
59
61
  { 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" },
60
62
  { key: "cells_per_plugin_budget", meaning: "measure at most the first n cells of the grid a plugin; 0 is every cell", default: "0" },
61
63
  { 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" },
64
+ { 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) },
62
65
  { key: "hostd_port", meaning: "plugin-hostd's command port the new pins are sent to; 0 sends none", default: "5555" },
63
66
  { key: "hostd_feedback_port", meaning: "plugin-hostd's feedback port, connected before the command port; 0 is the command port's next", default: "0" },
64
67
  { key: "settle_s", meaning: "wait until the plugin directories have been quiet this long before a run", default: "0" },
@@ -92,6 +95,9 @@ export function parseSyncArgs(argv) {
92
95
  let noDistributed = false;
93
96
  let noWait = false;
94
97
  let refreshPinsOnly = false;
98
+ let rejudge = false;
99
+ let memoryOnly = false;
100
+ let adapterOnly = false;
95
101
  let help = false;
96
102
  for (let i = 0; i < argv.length; i++) {
97
103
  const a = argv[i];
@@ -105,6 +111,12 @@ export function parseSyncArgs(argv) {
105
111
  noWait = true;
106
112
  else if (a === "--refresh-pins")
107
113
  refreshPinsOnly = true;
114
+ else if (a === "--rejudge")
115
+ rejudge = true;
116
+ else if (a === "--memory-only")
117
+ memoryOnly = true;
118
+ else if (a === "--adapter-only")
119
+ adapterOnly = true;
108
120
  else if (a === "--help")
109
121
  help = true;
110
122
  else if (a === "--grid-hosts")
@@ -121,7 +133,11 @@ export function parseSyncArgs(argv) {
121
133
  else
122
134
  return { ok: false, error: `unknown argument ${a}` };
123
135
  }
124
- return { ok: true, args: { ...(config === undefined ? {} : { config }), system, force, noDistributed, noWait, refreshPins: refreshPinsOnly, help, values } };
136
+ if (rejudge && (memoryOnly || adapterOnly || force || refreshPinsOnly))
137
+ return { ok: false, error: "--rejudge measures nothing: it goes with none of --memory-only, --adapter-only, --force and --refresh-pins" };
138
+ if ([memoryOnly, adapterOnly, refreshPinsOnly].filter(Boolean).length > 1)
139
+ return { ok: false, error: "--memory-only, --adapter-only and --refresh-pins are different runs: one at a time" };
140
+ return { ok: true, args: { ...(config === undefined ? {} : { config }), system, force, noDistributed, noWait, refreshPins: refreshPinsOnly, rejudge, memoryOnly, adapterOnly, help, values } };
125
141
  }
126
142
  export function syncHelpText() {
127
143
  const rows = SYNC_SETTINGS.map((s) => ` ${`${flagOf(s.key)} <value>`.padEnd(36)}${s.meaning}${s.default === "" ? "" : ` (default: ${s.default})`}`);
@@ -134,6 +150,9 @@ export function syncHelpText() {
134
150
  " --no-wait a run in progress is not waited for: exit 0, and the holder runs once more at its end",
135
151
  " --no-distributed read no distributed record: every plugin not recorded is measured",
136
152
  " --refresh-pins only bring every pin's files up to the ones plugin-hostd names, measuring nothing",
153
+ " --rejudge derive every record's verdicts, tiers and pin again from the figures it holds; measures nothing",
154
+ " --memory-only measure only the footprint of each recorded plugin at the Pi's cells and merge it; with --force, those already merged too",
155
+ " --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",
137
156
  " --grid-hosts the fast grid, the same as --grid hosts (7 cells with the shipped hosts)",
138
157
  " --help this text",
139
158
  ...rows,
@@ -184,6 +203,9 @@ export function resolveSyncSettings(file, args, env, home = homedir(), boardMode
184
203
  if (memoryLimit !== undefined && !memoryLimit.ok)
185
204
  return { ok: false, error: `memory_limit: ${memoryLimit.error}` };
186
205
  const memory = memoryBoundOf(memoryLimit?.bytes);
206
+ const memoryNotes = number("memory_notes", 1);
207
+ if (memoryNotes === undefined || !Number.isInteger(memoryNotes) || memoryNotes > 128)
208
+ return { ok: false, error: "memory_notes must be a whole number of notes, 1 to 128" };
187
209
  const profileId = get("host_profile") || "omx-clap-host";
188
210
  const hostProfile = shippedHostProfile(profileId);
189
211
  if (hostProfile === undefined)
@@ -215,6 +237,7 @@ export function resolveSyncSettings(file, args, env, home = homedir(), boardMode
215
237
  grid: grid.spec,
216
238
  cellsPerPluginBudget,
217
239
  ...(memory === undefined ? {} : { memory }),
240
+ memoryNotes,
218
241
  hostdPort,
219
242
  hostdFeedbackPort: feedbackPort === 0 && hostdPort !== 0 ? hostdPort + 1 : feedbackPort,
220
243
  settleS,
@@ -224,6 +247,29 @@ export function resolveSyncSettings(file, args, env, home = homedir(), boardMode
224
247
  },
225
248
  };
226
249
  }
250
+ /** lilv itself, one world over the whole search path (tools/lv2_resolve.py), as every host built on it resolves (#199). */
251
+ export const LILV_RESOLVE = (dirs) => {
252
+ const r = spawnSync("python3", [join(toolsDir(), "lv2_resolve.py")], {
253
+ encoding: "utf8", timeout: 120_000, killSignal: "SIGKILL", maxBuffer: 64 << 20, env: { ...process.env, LV2_PATH: dirs.join(":") },
254
+ });
255
+ if (r.error !== undefined || r.status !== 0)
256
+ return { problem: (r.stderr ?? "").trim().split("\n").pop() || (r.error?.message ?? `exit ${String(r.status)}`) };
257
+ try {
258
+ return JSON.parse(r.stdout).plugins;
259
+ }
260
+ catch (e) {
261
+ return { problem: `lv2_resolve.py printed no JSON: ${e.message}` };
262
+ }
263
+ };
264
+ /** Whether `path` exists and is, through its links, the file `target` is. */
265
+ function linksTo(path, target) {
266
+ try {
267
+ return realpathSync(path) === realpathSync(target);
268
+ }
269
+ catch {
270
+ return false;
271
+ }
272
+ }
227
273
  const CLAP_SUFFIX = ".clap";
228
274
  /** Every `.clap` under a directory, its subdirectories included, in name order; a directory reached twice is walked once. */
229
275
  function clapFiles(dir, seen, problems) {
@@ -270,9 +316,10 @@ function clapFiles(dir, seen, problems) {
270
316
  * LV2 directories that declares it, as plugin-hostd finds it. A directory that cannot be read is a problem named, and
271
317
  * the others are still walked.
272
318
  */
273
- export function discoverSyncItems(settings) {
319
+ export function discoverSyncItems(settings, lv2Resolve) {
274
320
  const items = [];
275
321
  const problems = [];
322
+ const notes = [];
276
323
  const seen = new Set();
277
324
  const clap = new Set();
278
325
  for (const dir of settings.clapDirs) {
@@ -284,6 +331,14 @@ export function discoverSyncItems(settings) {
284
331
  items.push({ key: `clap:${path}`, format: "clap", root: dirname(path), files: [basename(path)] });
285
332
  }
286
333
  }
334
+ // the bundle lilv takes for each plugin uri; without lilv, the first bundle found (and the log says so)
335
+ const resolved = lv2Resolve === undefined || settings.lv2Dirs.length === 0 ? undefined : lv2Resolve(settings.lv2Dirs);
336
+ const taken = new Map();
337
+ if (resolved !== undefined && "problem" in resolved)
338
+ notes.push(`lilv could not resolve the LV2 plugins (${resolved.problem}); each is keyed by the first bundle found`);
339
+ else if (resolved !== undefined)
340
+ for (const p of resolved)
341
+ taken.set(p.uri, p);
287
342
  const lv2 = new Set();
288
343
  for (const dir of settings.lv2Dirs) {
289
344
  let bundles;
@@ -304,14 +359,23 @@ export function discoverSyncItems(settings) {
304
359
  continue;
305
360
  }
306
361
  for (const p of manifestPlugins(ttl)) {
362
+ const lilv = taken.get(p.uri);
363
+ // lilv takes another bundle for this uri: this one is not the plugin a host loads
364
+ if (lilv !== undefined && resolve(lilv.bundle) !== resolve(bundle))
365
+ continue;
307
366
  if (lv2.has(p.uri))
308
367
  continue;
309
368
  lv2.add(p.uri);
310
- items.push({ key: `lv2:${p.uri}`, format: "lv2", root: bundle, files: ["manifest.ttl", ...p.files.filter((f) => f !== "manifest.ttl")], uri: p.uri });
369
+ const binary = lilv?.binary ?? (p.binary === undefined ? undefined : join(bundle, p.binary));
370
+ const files = ["manifest.ttl", ...p.files.filter((f) => f !== "manifest.ttl")];
371
+ // a binary named by an absolute IRI (a complement's) is tracked through the bundle's link to it, when it has one
372
+ if (binary !== undefined && !files.includes(basename(binary)) && linksTo(join(bundle, basename(binary)), binary))
373
+ files.push(basename(binary));
374
+ items.push({ key: `lv2:${p.uri}`, format: "lv2", root: bundle, files, uri: p.uri, ...(binary === undefined ? {} : { binary }) });
311
375
  }
312
376
  }
313
377
  }
314
- return { items, problems };
378
+ return { items, problems, notes };
315
379
  }
316
380
  /** The SHA-256 of a file's bytes, read in pieces over one descriptor. */
317
381
  export function fileSha256(path) {
@@ -365,6 +429,20 @@ export const SYNC_CODE = {
365
429
  /** The probe runner wrote no document to judge. */
366
430
  notMeasured: "qualify.sync.not-measured",
367
431
  };
432
+ /**
433
+ * Why a plugin is unmeasured because of the machine it ran on rather than what it is (#192): an EACCES, EPERM or
434
+ * ENOSPC the run hit, or a measurement that could not run or wrote no document. Such a plugin is measured again by
435
+ * the next run although its files are unchanged; a reason of the plugin's own (probe.limit.not-implemented, no such
436
+ * plugin, a crash) is not. `undefined` for every other outcome.
437
+ */
438
+ export function machineReason(p) {
439
+ if (p.outcome !== "unmeasured" || p.code !== SYNC_CODE.notMeasured)
440
+ return undefined;
441
+ const errno = /\b(EACCES|EPERM|ENOSPC)\b/.exec(p.detail ?? "")?.[1];
442
+ if (errno !== undefined)
443
+ return errno;
444
+ return /wrote no document|cannot run the probe runner/.test(p.detail ?? "") ? "the measurement wrote no document" : undefined;
445
+ }
368
446
  /** What the quick qualification never measures, stated in every record it writes. */
369
447
  export const QUICK_NOT_MEASURED = [{ dimension: "stability", code: HOSTING_CODE.stabilityUnsoaked }];
370
448
  const EMPTY_STATE = { schema: SYNC_STATE_SCHEMA, entries: {} };
@@ -402,6 +480,32 @@ export function recordedAsIs(item, entry) {
402
480
  const same = now.length === entry.files.length && now.every((f) => entry.files.some((e) => e.path === f.path && e.sha256 === f.sha256));
403
481
  return { same, files: now };
404
482
  }
483
+ /**
484
+ * The entry of an LV2 plugin a host now loads from another bundle than it was qualified in, when its binary is the
485
+ * same bytes (#199): the entry with the new bundle and the new bundle's file digests, its verdicts and records kept.
486
+ * `undefined` when the item is not that case: not LV2, no entry, the same bundle, a binary that cannot be read, or one
487
+ * whose bytes differ from the recorded binary's (then it is measured again).
488
+ */
489
+ export function sameBinaryElsewhere(item, entry) {
490
+ if (item.format !== "lv2" || entry === undefined || item.binary === undefined || resolve(entry.root) === resolve(item.root))
491
+ return undefined;
492
+ const was = entry.files.find((f) => basename(f.path) === basename(item.binary));
493
+ if (was === undefined)
494
+ return undefined;
495
+ let now;
496
+ try {
497
+ now = fileSha256(item.binary);
498
+ }
499
+ catch {
500
+ return undefined;
501
+ }
502
+ if (now !== was.sha256)
503
+ return undefined;
504
+ const files = digestFiles(item.root, item.files);
505
+ if (files === undefined)
506
+ return undefined;
507
+ return { ...entry, root: item.root, files };
508
+ }
405
509
  /**
406
510
  * The pin of a judged run with the files plugin-hostd collects for its plugin (plugin-qualify#228): the probe's list is
407
511
  * the probe's view, and an add checks the daemon's, so a pin holds the daemon's. No pin when plugin-hostd cannot name
@@ -595,15 +699,10 @@ function qualifyClap(ctx, item) {
595
699
  }
596
700
  /** An LV2 plugin, measured in a world of its own bundle only, as plugin-hostd runs a pinned LV2 plugin. */
597
701
  function qualifyLv2(ctx, item) {
598
- const world = mkdtempSync(join(tmpdir(), "plugin-qualify-sync-world-"));
599
- try {
600
- symlinkSync(item.root, join(world, basename(item.root)));
702
+ return lv2World(item.root, (world) => {
601
703
  const pass = runPass(ctx, { format: "lv2", uri: item.uri, bundle: item.root }, { ...ctx.env, LV2_PATH: world });
602
704
  return [...pass.failures.map((f) => ({ uri: item.uri, outcome: "unmeasured", code: SYNC_CODE.notMeasured, detail: f.problem })), ...judgedPlugins(ctx, pass.runs)];
603
- }
604
- finally {
605
- rmSync(world, { recursive: true, force: true });
606
- }
705
+ });
607
706
  }
608
707
  /** 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. */
609
708
  export function qualifyItem(ctx, item, files) {
@@ -913,7 +1012,7 @@ export function descriptorAccessProblems(dir) {
913
1012
  return problems;
914
1013
  }
915
1014
  export function summaryLine(s) {
916
- 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`;
1015
+ 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`;
917
1016
  }
918
1017
  /** The exit status `flock -n` returns when the lock is held (-E), told apart from a failure of flock itself. */
919
1018
  const LOCK_HELD = 99;
@@ -995,6 +1094,9 @@ function addSummaries(a, b) {
995
1094
  seconds: a.seconds + b.seconds,
996
1095
  ...(sent === 0 ? {} : { sent }),
997
1096
  ...(refreshed === 0 ? {} : { refreshed }),
1097
+ ...(b.rejudge === undefined ? (a.rejudge === undefined ? {} : { rejudge: a.rejudge }) : { rejudge: b.rejudge }),
1098
+ ...(b.memoryOnly === undefined ? (a.memoryOnly === undefined ? {} : { memoryOnly: a.memoryOnly }) : { memoryOnly: b.memoryOnly }),
1099
+ ...(b.adapterOnly === undefined ? (a.adapterOnly === undefined ? {} : { adapterOnly: a.adapterOnly }) : { adapterOnly: b.adapterOnly }),
998
1100
  };
999
1101
  }
1000
1102
  /** Whether this run's scan covers an entry: its root lies under one of the run's directories of its own format. */
@@ -1013,7 +1115,7 @@ function scannedBy(settings, entry) {
1013
1115
  async function runLocked(settings, options) {
1014
1116
  const log = options.log ?? (() => { });
1015
1117
  const env = options.env ?? process.env;
1016
- log(options.refreshOnly === true ? "pin refresh begins" : "sync run begins");
1118
+ 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");
1017
1119
  {
1018
1120
  const started = Date.now();
1019
1121
  const dirs = [...settings.clapDirs, ...settings.lv2Dirs];
@@ -1026,6 +1128,24 @@ async function runLocked(settings, options) {
1026
1128
  }
1027
1129
  if (options.refreshOnly === true)
1028
1130
  return sendRefreshed(settings, fresh.state, fresh.refreshed.length, started, log);
1131
+ if (options.rejudge === true) {
1132
+ const r = rejudgeState(ctx, fresh.state);
1133
+ if (JSON.stringify(r.state) !== JSON.stringify(fresh.state))
1134
+ writeFileWhole(settings.stateFile, `${JSON.stringify(r.state, null, 1)}\n`);
1135
+ const summary = await sendRefreshed(settings, r.state, fresh.refreshed.length, started, log);
1136
+ return { ...summary, rejudge: { changed: r.changed, unchanged: r.unchanged, owing: r.owing.length } };
1137
+ }
1138
+ if (options.adapterOnly === true) {
1139
+ const r = adapterPass(ctx, fresh.state, { ...options.adapterPass, force: options.force === true });
1140
+ log(`adapter runs under ${Object.entries(r.versions).map(([k, v]) => `${k} ${v}`).join(", ")}`);
1141
+ const summary = await sendRefreshed(settings, fresh.state, fresh.refreshed.length, started, log);
1142
+ return { ...summary, adapterOnly: { measured: r.measured, already: r.already, failed: r.failed } };
1143
+ }
1144
+ if (options.memoryOnly === true) {
1145
+ const r = memoryPass(ctx, fresh.state, { force: options.force === true, ...(options.probeRun === undefined ? {} : { run: options.probeRun }) });
1146
+ const summary = await sendRefreshed(settings, fresh.state, fresh.refreshed.length, started, log);
1147
+ return { ...summary, memoryOnly: { measured: r.measured, already: r.already, failed: r.failed } };
1148
+ }
1029
1149
  await waitQuiet(dirs, settings.settleS, log);
1030
1150
  const count = { qualified: 0, skipped: 0, refused: 0, crashed: 0, hung: 0, unmeasured: 0, distributed: 0 };
1031
1151
  const layer = distributedLayerOf(settings, options.force === true, log);
@@ -1040,12 +1160,18 @@ async function runLocked(settings, options) {
1040
1160
  const stateBefore = state;
1041
1161
  let unreadable = false;
1042
1162
  const lv2Pinned = [];
1163
+ // the plugins measured this run, and how many of them the machine left unmeasured, by reason (#192)
1164
+ let measuredHere = 0;
1165
+ const byMachine = new Map();
1043
1166
  // a change made while a pass ran is caught by one more pass, a few at most
1044
1167
  for (let pass = 0; pass < 3; pass++) {
1045
1168
  const signature = directoriesSignature(dirs);
1046
- const { items, problems } = discoverSyncItems(settings);
1169
+ const { items, problems, notes } = discoverSyncItems(settings, options.lv2Resolve ?? LILV_RESOLVE);
1047
1170
  for (const p of problems)
1048
1171
  log(p);
1172
+ if (pass === 0)
1173
+ for (const n of notes)
1174
+ log(n);
1049
1175
  unreadable = problems.length > 0;
1050
1176
  const found = new Set(items.map((i) => i.key));
1051
1177
  const entries = {};
@@ -1063,11 +1189,29 @@ async function runLocked(settings, options) {
1063
1189
  }
1064
1190
  for (const item of items) {
1065
1191
  const recorded = recordedAsIs(item, entries[item.key]);
1066
- if (recorded.same && options.force !== true) {
1192
+ const retry = recorded.same ? entries[item.key].plugins.map(machineReason).find((r) => r !== undefined) : undefined;
1193
+ if (recorded.same && options.force !== true && retry === undefined) {
1067
1194
  if (pass === 0)
1068
1195
  count.skipped += entries[item.key].plugins.length;
1069
1196
  continue;
1070
1197
  }
1198
+ // an LV2 plugin a host now loads from another bundle with the same binary (a complement that restates its TTL,
1199
+ // #199): the record stands, keyed by its uri and its binary; only the bundle, its files and the pins' files move
1200
+ const moved = options.force === true ? undefined : sameBinaryElsewhere(item, entries[item.key]);
1201
+ if (moved !== undefined) {
1202
+ entries[item.key] = { ...moved, plugins: moved.plugins.map((p) => ({ ...p, ...(p.pin === undefined ? {} : { pin: withHostdFiles(ctx, p.pin) }) })) };
1203
+ if (item.format === "lv2")
1204
+ lv2Pinned.push(...entries[item.key].plugins.filter((p) => p.pin !== undefined).map((p) => p.uri));
1205
+ if (pass === 0)
1206
+ count.skipped += moved.plugins.length;
1207
+ 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`);
1208
+ state = { schema: SYNC_STATE_SCHEMA, entries };
1209
+ writeFileWhole(settings.stateFile, `${JSON.stringify(state, null, 1)}\n`);
1210
+ writeFileWhole(settings.pinFile, syncPinsText(state));
1211
+ continue;
1212
+ }
1213
+ if (recorded.same && retry !== undefined)
1214
+ log(`${item.key}: unmeasured last run by the machine (${retry}); measured again`);
1071
1215
  const files = recorded.files ?? digestFiles(item.root, item.files);
1072
1216
  if (files === undefined) {
1073
1217
  log(`${item.key}: a file of it cannot be read; skipped this run`);
@@ -1092,6 +1236,13 @@ async function runLocked(settings, options) {
1092
1236
  lv2Pinned.push(...entry.plugins.filter((p) => p.pin !== undefined).map((p) => p.uri));
1093
1237
  for (const p of entry.plugins) {
1094
1238
  count[p.outcome]++;
1239
+ measuredHere++;
1240
+ // a reason of the machine is said once: the plugins after the first are counted, not listed
1241
+ const why = machineReason(p);
1242
+ if (why !== undefined)
1243
+ byMachine.set(why, (byMachine.get(why) ?? 0) + 1);
1244
+ if (why !== undefined && byMachine.get(why) > 1)
1245
+ continue;
1095
1246
  log(`${p.uri}: ${p.outcome}${p.code === undefined ? "" : ` (${p.code})`}${p.detail === undefined ? "" : `: ${p.detail}`}`);
1096
1247
  }
1097
1248
  state = { schema: SYNC_STATE_SCHEMA, entries };
@@ -1133,8 +1284,16 @@ async function runLocked(settings, options) {
1133
1284
  if (replies[i] !== "resp 0")
1134
1285
  log(`plugin-hostd answered ${replies[i]} to ${changed[i].split(" ")[1]}`);
1135
1286
  const taken = replies.filter((r) => r === "resp 0").length;
1287
+ for (const [why, n] of byMachine)
1288
+ if (n > 1)
1289
+ log(`${String(n)} plugins unmeasured by the machine (${why}); the first is listed above`);
1290
+ const [only] = byMachine.size === 1 ? [...byMachine] : [];
1291
+ const machineFailure = only !== undefined && only[1] === measuredHere ? only[0] : undefined;
1292
+ if (machineFailure !== undefined)
1293
+ log(`every plugin this run measured was left unmeasured by the machine (${machineFailure}): the run failed`);
1136
1294
  return {
1137
1295
  ...count,
1296
+ ...(machineFailure === undefined ? {} : { machineFailure }),
1138
1297
  seconds: (Date.now() - started) / 1000,
1139
1298
  ...(taken === 0 ? {} : { sent: taken }),
1140
1299
  ...(fresh.refreshed.length === 0 ? {} : { refreshed: fresh.refreshed.length }),