@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
 
26
26
  import { clapQualifyTools } from "./clap-qualify.js";
27
27
  import { DISTRIBUTED_DIR_SYSTEM, EMPTY_LAYER, applyDistributed, distributedUri, hostClassOfBoard, matchDistributed, readBoardModel, readDistributedLayer, type DistributedLayer } from "./distributed-records.js";
@@ -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
- return { ok: true, args: { ...(config === undefined ? {} : { config }), system, force, noDistributed, noWait, refreshPins: refreshPinsOnly, help, values } };
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(settings: Pick<SyncSettings, "lv2Dirs" | "clapDirs">): { items: SyncItem[]; problems: string[] } {
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
- items.push({ key: `lv2:${p.uri}`, format: "lv2", root: bundle, files: ["manifest.ttl", ...p.files.filter((f) => f !== "manifest.ttl")], uri: p.uri });
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
- const world = mkdtempSync(join(tmpdir(), "plugin-qualify-sync-world-"));
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
- } finally {
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,9 +1249,21 @@ 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
 
1258
+ /** Whether this run's scan covers an entry: its root lies under one of the run's directories of its own format. */
1259
+ function scannedBy(settings: Pick<SyncSettings, "lv2Dirs" | "clapDirs">, entry: SyncEntry): boolean {
1260
+ const root = resolve(entry.root);
1261
+ return (entry.format === "clap" ? settings.clapDirs : settings.lv2Dirs).some((d) => {
1262
+ const dir = resolve(d);
1263
+ return root === dir || root.startsWith(dir.endsWith(sep) ? dir : dir + sep);
1264
+ });
1265
+ }
1266
+
1129
1267
  /**
1130
1268
  * One run: wait for quiet, qualify what is new or changed, drop what is gone, write the state, the records, the
1131
1269
  * descriptors and the pin file, and send the changed pins to a running daemon. The state and the pin file are written
@@ -1134,7 +1272,7 @@ function addSummaries(a: SyncSummary, b: SyncSummary): SyncSummary {
1134
1272
  async function runLocked(settings: SyncSettings, options: SyncRunOptions): Promise<SyncSummary> {
1135
1273
  const log = options.log ?? (() => {});
1136
1274
  const env = options.env ?? process.env;
1137
- 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");
1138
1276
  {
1139
1277
  const started = Date.now();
1140
1278
  const dirs = [...settings.clapDirs, ...settings.lv2Dirs];
@@ -1146,6 +1284,23 @@ async function runLocked(settings: SyncSettings, options: SyncRunOptions): Promi
1146
1284
  writeFileWhole(settings.stateFile, `${JSON.stringify(fresh.state, null, 1)}\n`);
1147
1285
  }
1148
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
+ }
1149
1304
  await waitQuiet(dirs, settings.settleS, log);
1150
1305
  const count = { qualified: 0, skipped: 0, refused: 0, crashed: 0, hung: 0, unmeasured: 0, distributed: 0 };
1151
1306
  const layer = distributedLayerOf(settings, options.force === true, log);
@@ -1159,22 +1314,47 @@ async function runLocked(settings: SyncSettings, options: SyncRunOptions): Promi
1159
1314
  const stateBefore = state;
1160
1315
  let unreadable = false;
1161
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>();
1162
1320
  // a change made while a pass ran is caught by one more pass, a few at most
1163
1321
  for (let pass = 0; pass < 3; pass++) {
1164
1322
  const signature = directoriesSignature(dirs);
1165
- const { items, problems } = discoverSyncItems(settings);
1323
+ const { items, problems, notes } = discoverSyncItems(settings, options.lv2Resolve ?? LILV_RESOLVE);
1166
1324
  for (const p of problems) log(p);
1325
+ if (pass === 0) for (const n of notes) log(n);
1167
1326
  unreadable = problems.length > 0;
1168
1327
  const found = new Set(items.map((i) => i.key));
1169
1328
  const entries: Record<string, SyncEntry> = {};
1170
- for (const [key, entry] of Object.entries(state.entries)) if (found.has(key)) entries[key] = entry;
1171
- for (const key of Object.keys(state.entries)) if (!found.has(key)) log(`${key}: gone, its pin is dropped`);
1329
+ for (const [key, entry] of Object.entries(state.entries)) {
1330
+ if (found.has(key)) entries[key] = entry;
1331
+ // a run scoped to some directories keeps what it did not scan: gone means not found where this run looked
1332
+ else if (!scannedBy(settings, entry)) {
1333
+ entries[key] = entry;
1334
+ if (pass === 0) log(`${key}: not under a directory this run scans; its pin is kept`);
1335
+ } else log(`${key}: gone, its pin is dropped`);
1336
+ }
1172
1337
  for (const item of items) {
1173
1338
  const recorded = recordedAsIs(item, entries[item.key]);
1174
- if (recorded.same && options.force !== true) {
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) {
1175
1341
  if (pass === 0) count.skipped += entries[item.key]!.plugins.length;
1176
1342
  continue;
1177
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`);
1178
1358
  const files = recorded.files ?? digestFiles(item.root, item.files);
1179
1359
  if (files === undefined) {
1180
1360
  log(`${item.key}: a file of it cannot be read; skipped this run`);
@@ -1196,6 +1376,11 @@ async function runLocked(settings: SyncSettings, options: SyncRunOptions): Promi
1196
1376
  if (item.format === "lv2") lv2Pinned.push(...entry.plugins.filter((p) => p.pin !== undefined).map((p) => p.uri));
1197
1377
  for (const p of entry.plugins) {
1198
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;
1199
1384
  log(`${p.uri}: ${p.outcome}${p.code === undefined ? "" : ` (${p.code})`}${p.detail === undefined ? "" : `: ${p.detail}`}`);
1200
1385
  }
1201
1386
  state = { schema: SYNC_STATE_SCHEMA, entries };
@@ -1229,8 +1414,13 @@ async function runLocked(settings: SyncSettings, options: SyncRunOptions): Promi
1229
1414
  const replies = "replies" in sent ? sent.replies : [];
1230
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]!}`);
1231
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`);
1232
1421
  return {
1233
1422
  ...count,
1423
+ ...(machineFailure === undefined ? {} : { machineFailure }),
1234
1424
  seconds: (Date.now() - started) / 1000,
1235
1425
  ...(taken === 0 ? {} : { sent: taken }),
1236
1426
  ...(fresh.refreshed.length === 0 ? {} : { refreshed: fresh.refreshed.length }),
@@ -17,6 +17,7 @@ OPERATIONAL_RATE_FLOOR = 48000
17
17
  QUALIFY_RATES = (44100, 48000, 96000, 192000)
18
18
  QUALIFY_QUANTA = (128, 256, 512, 1024)
19
19
  PLUGIN_TIMEOUT_S = 15
20
+ ADDRESS_SPACE_LIMIT_RAM_FRACTION = 1
20
21
  BOOT_TIMEOUT_S = 120
21
22
  COST_TIMEOUT_S = 60
22
23
  DESCRIPTOR_DIR_KEY = "descriptor_dir"
@@ -42,6 +42,7 @@ Usage:
42
42
  import argparse
43
43
  import json
44
44
  import os
45
+ import resource
45
46
  import select
46
47
  import shlex
47
48
  import subprocess
@@ -53,10 +54,13 @@ from collections import namedtuple
53
54
  # declarations (with the rate list and timeouts), generated from `src/qualify-declarations.ts` because this script cannot import
54
55
  # a TS module (2026-09-17 declare-derive audit, F8) — see `_declarations.py`'s own header for the
55
56
  # regenerate/verify commands.
56
- from _declarations import (BOOT_TIMEOUT_S, COST_REFERENCE_RATE, COST_TIMEOUT_S,
57
+ from _declarations import (ADDRESS_SPACE_LIMIT_RAM_FRACTION, BOOT_TIMEOUT_S, COST_REFERENCE_RATE, COST_TIMEOUT_S,
57
58
  MEASURE_BASE_RATE, OPERATIONAL_RATE_FLOOR, PLUGIN_TIMEOUT_S,
58
59
  QUALIFY_RATES, QUANTUM_FRAMES)
59
60
 
61
+ # the outcome of a worker past its address space, the same word as a direct spawn of plugin-qualify's (src/direct-limit.ts)
62
+ MEMORY_LIMIT_EXCEEDED = "memory-limit-exceeded"
63
+
60
64
  try: # numpy is only needed by the worker (and as a fast path in the pure helpers)
61
65
  import numpy as _np
62
66
  except ImportError: # pragma: no cover - CI runs the pure tests without numpy
@@ -172,7 +176,7 @@ COST_TONE_AMP = 0.5
172
176
  # whose tail is genuinely fat — the one that xruns mid-show — while being a stable
173
177
  # statistic at this sample count.
174
178
  COST_PERCENTILE = 95.0
175
- # Cost is anchored at the rig's canonical rate: the headline figure the operator asked for is
179
+ # Cost is anchored at the rig's canonical rate: the headline figure asked for is
176
180
  # "how many of these fit in one core at the rig's rate". `COST_REFERENCE_RATE` is imported above
177
181
  # from `_declarations.py` — the same fact `cpu-cost.ts` declares, not a second 96000 literal.
178
182
  # A cost probe runs many more blocks than a latency probe; give it room.
@@ -1908,6 +1912,8 @@ def worker_main(rate, block, measure=False, cost=False, cpu=None,
1908
1912
  host, uri, declared, res["measuredFrames"], job["sweep"])
1909
1913
  if swept:
1910
1914
  res["sweep"] = swept
1915
+ except MemoryError:
1916
+ res = {"error": MEMORY_LIMIT_EXCEEDED}
1911
1917
  except Exception as e: # noqa: BLE001 - report, don't die
1912
1918
  res = {"error": f"probe error: {e}"}
1913
1919
  if "measuredFrames" in res:
@@ -1924,6 +1930,8 @@ def worker_main(rate, block, measure=False, cost=False, cpu=None,
1924
1930
  symbol = arg
1925
1931
  try:
1926
1932
  frames, reason = probe_one(host, uri, symbol)
1933
+ except MemoryError:
1934
+ frames, reason = None, MEMORY_LIMIT_EXCEEDED
1927
1935
  except Exception as e: # noqa: BLE001 - report, don't die, on python-level errors
1928
1936
  frames, reason = None, f"probe error: {e}"
1929
1937
  if frames is None:
@@ -1935,6 +1943,20 @@ def worker_main(rate, block, measure=False, cost=False, cpu=None,
1935
1943
  # --- the parent: batch loop with crash containment -----------------------------
1936
1944
 
1937
1945
 
1946
+ def address_space_limit(ram_bytes=None):
1947
+ """What the worker runs under: RLIMIT_AS at ADDRESS_SPACE_LIMIT_RAM_FRACTION of the machine's RAM, since a direct
1948
+ spawn has no runner to give it a cgroup; set between fork and exec. A plugin past it fails its allocation, which the
1949
+ worker reports as `MEMORY_LIMIT_EXCEEDED` when Python sees it and which crash containment takes when the plugin dies."""
1950
+ if ram_bytes is None:
1951
+ ram_bytes = os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES")
1952
+ limit = int(ram_bytes * ADDRESS_SPACE_LIMIT_RAM_FRACTION)
1953
+
1954
+ def apply():
1955
+ resource.setrlimit(resource.RLIMIT_AS, (limit, limit))
1956
+
1957
+ return apply
1958
+
1959
+
1938
1960
  class _Worker:
1939
1961
  """Handle on one worker subprocess: spawn, job/reply exchange, kill."""
1940
1962
 
@@ -1942,6 +1964,7 @@ class _Worker:
1942
1964
  self.proc = subprocess.Popen(
1943
1965
  cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
1944
1966
  stderr=subprocess.DEVNULL, text=True, bufsize=1,
1967
+ preexec_fn=address_space_limit(),
1945
1968
  )
1946
1969
  ready = self._read_line(boot_timeout)
1947
1970
  parsed = parse_worker_line(ready)
@@ -33,6 +33,7 @@ const PY_NAMES = [
33
33
  'QUALIFY_RATES',
34
34
  'QUALIFY_QUANTA',
35
35
  'PLUGIN_TIMEOUT_S',
36
+ 'ADDRESS_SPACE_LIMIT_RAM_FRACTION',
36
37
  'BOOT_TIMEOUT_S',
37
38
  'COST_TIMEOUT_S',
38
39
  'DESCRIPTOR_DIR_KEY',
@@ -60,6 +60,7 @@ import {
60
60
  urisFromScan,
61
61
  } from "../dist/measurement/index.js";
62
62
  import { LV2_HOSTING_TIMEOUT_S, LV2_MEASURE_TOOL_NAME, RT_INTERPOSE_NAME } from "../dist/qualify-declarations.js";
63
+ import { addressSpaceLimited, directOutcome } from "../dist/direct-limit.js";
63
64
  import { halfPopulatedRefusal, locateNativeTools } from "../dist/tool-paths.js";
64
65
 
65
66
  const HERE = dirname(fileURLToPath(import.meta.url));
@@ -135,9 +136,10 @@ function runHosting(step, uris, onProgress) {
135
136
  const lines = [];
136
137
  onProgress(step, { kind: "phase", plugins: uris.length, rate: Number(step.args[0]) });
137
138
  for (const uri of uris) {
138
- const r = spawnSync(step.command, [uri, ...step.args], {
139
+ const limited = addressSpaceLimited(step.command, [uri, ...step.args]);
140
+ const r = spawnSync(limited.command, [...limited.args], {
139
141
  encoding: "utf8",
140
- env: { ...process.env, ...step.env },
142
+ env: { ...process.env, ...step.env, ...limited.env },
141
143
  stdio: ["ignore", "pipe", "inherit"],
142
144
  timeout: LV2_HOSTING_TIMEOUT_S * 1000,
143
145
  maxBuffer: 16 << 20,
@@ -149,9 +151,10 @@ function runHosting(step, uris, onProgress) {
149
151
  ...(r.error?.code === "ETIMEDOUT" ? { timedOut: true } : {}),
150
152
  stdout: r.stdout ?? "",
151
153
  });
152
- process.stderr.write(` ${uri}: exit ${line.exit}\n`);
154
+ const limitHit = directOutcome(r.status, limited);
155
+ process.stderr.write(` ${uri}: exit ${line.exit}${limitHit === undefined ? "" : ` (${limitHit.outcome})`}\n`);
153
156
  onProgress(step, { kind: "plugin" });
154
- lines.push(line);
157
+ lines.push(limitHit === undefined ? line : { ...line, ...limitHit });
155
158
  }
156
159
  writeFileSync(step.produces, lines.map((l) => `${JSON.stringify(l)}\n`).join(""));
157
160
  return lines;
@@ -16,7 +16,7 @@ body, and judges it against the plugin's own descriptor params:
16
16
  listed — refuse or report, never drop.
17
17
 
18
18
  Only the bundles lilv loads from LV2_PATH are read, which is what the scan reads: the presets a
19
- plugin ships, never one another host saved in a user's home (ruling 2). The output is sorted by
19
+ plugin ships, never one another host saved in a user's home (decision 2). The output is sorted by
20
20
  preset URI and the values by symbol, so the same install yields the same bytes.
21
21
  """
22
22
 
@@ -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())