@openmixer/plugin-qualify 0.17.5 → 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 (109) hide show
  1. package/README.md +127 -13
  2. package/bin/plugin-qualify-sync.mjs +3 -1
  3. package/dist/clap-qualify.d.ts +3 -1
  4. package/dist/clap-qualify.js +7 -1
  5. package/dist/clap-qualify.js.map +1 -1
  6. package/dist/direct-limit.d.ts +27 -0
  7. package/dist/direct-limit.js +52 -0
  8. package/dist/direct-limit.js.map +1 -0
  9. package/dist/distributed-records.d.ts +4 -2
  10. package/dist/distributed-records.js +1 -1
  11. package/dist/distributed-records.js.map +1 -1
  12. package/dist/host-admission.d.ts +12 -2
  13. package/dist/host-admission.js +24 -4
  14. package/dist/host-admission.js.map +1 -1
  15. package/dist/host-profiles.d.ts +8 -0
  16. package/dist/host-profiles.js +16 -1
  17. package/dist/host-profiles.js.map +1 -1
  18. package/dist/hosting-codes.generated.d.ts +10 -0
  19. package/dist/hosting-codes.generated.js +10 -0
  20. package/dist/hosting-codes.generated.js.map +1 -1
  21. package/dist/hosting-suitability.d.ts +34 -8
  22. package/dist/hosting-suitability.js +92 -7
  23. package/dist/hosting-suitability.js.map +1 -1
  24. package/dist/index.d.ts +3 -0
  25. package/dist/index.js +3 -0
  26. package/dist/index.js.map +1 -1
  27. package/dist/measurement/format.d.ts +54 -8
  28. package/dist/measurement/format.js +54 -3
  29. package/dist/measurement/format.js.map +1 -1
  30. package/dist/measurement/plan.d.ts +3 -3
  31. package/dist/measurement/plan.js +1 -1
  32. package/dist/measurement-grid.d.ts +8 -0
  33. package/dist/measurement-grid.js +4 -0
  34. package/dist/measurement-grid.js.map +1 -1
  35. package/dist/memory-bound.d.ts +6 -0
  36. package/dist/memory-bound.js +8 -0
  37. package/dist/memory-bound.js.map +1 -1
  38. package/dist/memory-budget.d.ts +39 -0
  39. package/dist/memory-budget.js +32 -0
  40. package/dist/memory-budget.js.map +1 -0
  41. package/dist/probe-judge.d.ts +11 -0
  42. package/dist/probe-judge.js +26 -10
  43. package/dist/probe-judge.js.map +1 -1
  44. package/dist/probe-merge.js +6 -5
  45. package/dist/probe-merge.js.map +1 -1
  46. package/dist/probe-output-fixtures.d.ts +6 -0
  47. package/dist/probe-output-fixtures.js +8 -0
  48. package/dist/probe-output-fixtures.js.map +1 -1
  49. package/dist/probe-output.d.ts +4 -2
  50. package/dist/probe-output.js +2 -1
  51. package/dist/probe-output.js.map +1 -1
  52. package/dist/probe-runner.js +7 -3
  53. package/dist/probe-runner.js.map +1 -1
  54. package/dist/qualification-record.d.ts +116 -10
  55. package/dist/qualification-record.js +34 -30
  56. package/dist/qualification-record.js.map +1 -1
  57. package/dist/qualify-adapter-pass.d.ts +67 -0
  58. package/dist/qualify-adapter-pass.js +255 -0
  59. package/dist/qualify-adapter-pass.js.map +1 -0
  60. package/dist/qualify-declarations.d.ts +53 -9
  61. package/dist/qualify-declarations.js +54 -2
  62. package/dist/qualify-declarations.js.map +1 -1
  63. package/dist/qualify-rejudge.d.ts +81 -0
  64. package/dist/qualify-rejudge.js +421 -0
  65. package/dist/qualify-rejudge.js.map +1 -0
  66. package/dist/qualify-sync.d.ts +72 -1
  67. package/dist/qualify-sync.js +194 -21
  68. package/dist/qualify-sync.js.map +1 -1
  69. package/fixtures/records-0.16.1/http___example.org_raffo@unversioned-2060c09ff626.default.probe.json +46 -0
  70. package/fixtures/records-0.16.1/http___example.org_raffo@unversioned-2060c09ff626.default.qualification.json +185 -0
  71. package/fixtures/records-0.16.1/http___lv2plug.in_plugins_eg-amp@unversioned-f046a60efd45.default.probe.json +51 -0
  72. package/fixtures/records-0.16.1/http___lv2plug.in_plugins_eg-amp@unversioned-f046a60efd45.default.qualification.json +232 -0
  73. package/fixtures/records-0.16.1/https___github.com_lucianodato_noise-repellent@unversioned-c3b5e21dd9a7.default.probe.json +51 -0
  74. package/fixtures/records-0.16.1/https___github.com_lucianodato_noise-repellent@unversioned-c3b5e21dd9a7.default.qualification.json +245 -0
  75. package/fixtures/records-0.16.1/in.lsp-plug.impulse_responses_mono@unversioned-d5235d2f5f53.custom.probe.json +52 -0
  76. package/fixtures/records-0.16.1/in.lsp-plug.impulse_responses_mono@unversioned-d5235d2f5f53.custom.qualification.json +145 -0
  77. package/fixtures/records-0.16.1/in.lsp-plug.trigger_midi_mono@unversioned-d5235d2f5f53.custom.probe.json +52 -0
  78. package/fixtures/records-0.16.1/in.lsp-plug.trigger_midi_mono@unversioned-d5235d2f5f53.custom.qualification.json +155 -0
  79. package/package.json +1 -1
  80. package/src/clap-qualify.ts +8 -1
  81. package/src/direct-limit.ts +68 -0
  82. package/src/distributed-records.ts +1 -1
  83. package/src/host-admission.ts +27 -4
  84. package/src/host-profiles.ts +17 -1
  85. package/src/hosting-codes.generated.ts +10 -0
  86. package/src/hosting-suitability.ts +105 -11
  87. package/src/index.ts +3 -0
  88. package/src/measurement/format.ts +97 -3
  89. package/src/measurement/plan.ts +3 -3
  90. package/src/measurement-grid.ts +5 -0
  91. package/src/memory-bound.ts +10 -0
  92. package/src/memory-budget.ts +55 -0
  93. package/src/probe-judge.ts +29 -9
  94. package/src/probe-merge.ts +6 -5
  95. package/src/probe-output-fixtures.ts +10 -1
  96. package/src/probe-output.ts +5 -2
  97. package/src/probe-runner.ts +5 -3
  98. package/src/qualification-record.ts +121 -18
  99. package/src/qualify-adapter-pass.ts +278 -0
  100. package/src/qualify-declarations.ts +67 -3
  101. package/src/qualify-rejudge.ts +452 -0
  102. package/src/qualify-sync.ts +210 -20
  103. package/tools/_declarations.py +1 -0
  104. package/tools/benchmark.py +25 -2
  105. package/tools/gen-python-declarations.mjs +1 -0
  106. package/tools/lv2-measure.mjs +7 -4
  107. package/tools/lv2_presets.py +1 -1
  108. package/tools/lv2_resolve.py +49 -0
  109. package/tools/mod-host-admit.mjs +23 -6
@@ -18,10 +18,10 @@
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
- import { basename, dirname, join, resolve } from "node:path";
24
+ import { basename, dirname, join, resolve, sep } from "node:path";
25
25
  import { clapQualifyTools } from "./clap-qualify.js";
26
26
  import { DISTRIBUTED_DIR_SYSTEM, EMPTY_LAYER, applyDistributed, distributedUri, hostClassOfBoard, matchDistributed, readBoardModel, readDistributedLayer } from "./distributed-records.js";
27
27
  import { clapSearchPath, parseClapScan } from "./clap-loader.js";
@@ -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,8 +1094,19 @@ 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
  }
1102
+ /** Whether this run's scan covers an entry: its root lies under one of the run's directories of its own format. */
1103
+ function scannedBy(settings, entry) {
1104
+ const root = resolve(entry.root);
1105
+ return (entry.format === "clap" ? settings.clapDirs : settings.lv2Dirs).some((d) => {
1106
+ const dir = resolve(d);
1107
+ return root === dir || root.startsWith(dir.endsWith(sep) ? dir : dir + sep);
1108
+ });
1109
+ }
1000
1110
  /**
1001
1111
  * One run: wait for quiet, qualify what is new or changed, drop what is gone, write the state, the records, the
1002
1112
  * descriptors and the pin file, and send the changed pins to a running daemon. The state and the pin file are written
@@ -1005,7 +1115,7 @@ function addSummaries(a, b) {
1005
1115
  async function runLocked(settings, options) {
1006
1116
  const log = options.log ?? (() => { });
1007
1117
  const env = options.env ?? process.env;
1008
- 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");
1009
1119
  {
1010
1120
  const started = Date.now();
1011
1121
  const dirs = [...settings.clapDirs, ...settings.lv2Dirs];
@@ -1018,6 +1128,24 @@ async function runLocked(settings, options) {
1018
1128
  }
1019
1129
  if (options.refreshOnly === true)
1020
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
+ }
1021
1149
  await waitQuiet(dirs, settings.settleS, log);
1022
1150
  const count = { qualified: 0, skipped: 0, refused: 0, crashed: 0, hung: 0, unmeasured: 0, distributed: 0 };
1023
1151
  const layer = distributedLayerOf(settings, options.force === true, log);
@@ -1032,28 +1160,58 @@ async function runLocked(settings, options) {
1032
1160
  const stateBefore = state;
1033
1161
  let unreadable = false;
1034
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();
1035
1166
  // a change made while a pass ran is caught by one more pass, a few at most
1036
1167
  for (let pass = 0; pass < 3; pass++) {
1037
1168
  const signature = directoriesSignature(dirs);
1038
- const { items, problems } = discoverSyncItems(settings);
1169
+ const { items, problems, notes } = discoverSyncItems(settings, options.lv2Resolve ?? LILV_RESOLVE);
1039
1170
  for (const p of problems)
1040
1171
  log(p);
1172
+ if (pass === 0)
1173
+ for (const n of notes)
1174
+ log(n);
1041
1175
  unreadable = problems.length > 0;
1042
1176
  const found = new Set(items.map((i) => i.key));
1043
1177
  const entries = {};
1044
- for (const [key, entry] of Object.entries(state.entries))
1178
+ for (const [key, entry] of Object.entries(state.entries)) {
1045
1179
  if (found.has(key))
1046
1180
  entries[key] = entry;
1047
- for (const key of Object.keys(state.entries))
1048
- if (!found.has(key))
1181
+ // a run scoped to some directories keeps what it did not scan: gone means not found where this run looked
1182
+ else if (!scannedBy(settings, entry)) {
1183
+ entries[key] = entry;
1184
+ if (pass === 0)
1185
+ log(`${key}: not under a directory this run scans; its pin is kept`);
1186
+ }
1187
+ else
1049
1188
  log(`${key}: gone, its pin is dropped`);
1189
+ }
1050
1190
  for (const item of items) {
1051
1191
  const recorded = recordedAsIs(item, entries[item.key]);
1052
- 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) {
1053
1194
  if (pass === 0)
1054
1195
  count.skipped += entries[item.key].plugins.length;
1055
1196
  continue;
1056
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`);
1057
1215
  const files = recorded.files ?? digestFiles(item.root, item.files);
1058
1216
  if (files === undefined) {
1059
1217
  log(`${item.key}: a file of it cannot be read; skipped this run`);
@@ -1078,6 +1236,13 @@ async function runLocked(settings, options) {
1078
1236
  lv2Pinned.push(...entry.plugins.filter((p) => p.pin !== undefined).map((p) => p.uri));
1079
1237
  for (const p of entry.plugins) {
1080
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;
1081
1246
  log(`${p.uri}: ${p.outcome}${p.code === undefined ? "" : ` (${p.code})`}${p.detail === undefined ? "" : `: ${p.detail}`}`);
1082
1247
  }
1083
1248
  state = { schema: SYNC_STATE_SCHEMA, entries };
@@ -1119,8 +1284,16 @@ async function runLocked(settings, options) {
1119
1284
  if (replies[i] !== "resp 0")
1120
1285
  log(`plugin-hostd answered ${replies[i]} to ${changed[i].split(" ")[1]}`);
1121
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`);
1122
1294
  return {
1123
1295
  ...count,
1296
+ ...(machineFailure === undefined ? {} : { machineFailure }),
1124
1297
  seconds: (Date.now() - started) / 1000,
1125
1298
  ...(taken === 0 ? {} : { sent: taken }),
1126
1299
  ...(fresh.refreshed.length === 0 ? {} : { refreshed: fresh.refreshed.length }),