@openmixer/plugin-qualify 0.16.1 → 0.17.0

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 (61) hide show
  1. package/README.md +153 -6
  2. package/bin/plugin-qualify-records-package.mjs +31 -0
  3. package/bin/plugin-qualify-sync.mjs +6 -1
  4. package/bin/plugin-qualify.mjs +12 -1
  5. package/dist/distributed-records.d.ts +88 -0
  6. package/dist/distributed-records.js +210 -0
  7. package/dist/distributed-records.js.map +1 -0
  8. package/dist/index.d.ts +5 -0
  9. package/dist/index.js +5 -0
  10. package/dist/index.js.map +1 -1
  11. package/dist/measurement-grid.d.ts +49 -0
  12. package/dist/measurement-grid.js +76 -0
  13. package/dist/measurement-grid.js.map +1 -0
  14. package/dist/memory-bound.d.ts +23 -0
  15. package/dist/memory-bound.js +46 -0
  16. package/dist/memory-bound.js.map +1 -0
  17. package/dist/probe-judge.js +22 -1
  18. package/dist/probe-judge.js.map +1 -1
  19. package/dist/probe-merge.d.ts +30 -0
  20. package/dist/probe-merge.js +80 -0
  21. package/dist/probe-merge.js.map +1 -0
  22. package/dist/probe-output.d.ts +8 -0
  23. package/dist/probe-output.js +7 -0
  24. package/dist/probe-output.js.map +1 -1
  25. package/dist/probe-runner.d.ts +26 -2
  26. package/dist/probe-runner.js +38 -10
  27. package/dist/probe-runner.js.map +1 -1
  28. package/dist/qualification-record.d.ts +19 -1
  29. package/dist/qualification-record.js +30 -1
  30. package/dist/qualification-record.js.map +1 -1
  31. package/dist/qualify-cli.d.ts +9 -0
  32. package/dist/qualify-cli.js +30 -1
  33. package/dist/qualify-cli.js.map +1 -1
  34. package/dist/qualify-declarations.d.ts +15 -0
  35. package/dist/qualify-declarations.js +15 -0
  36. package/dist/qualify-declarations.js.map +1 -1
  37. package/dist/qualify-options.js +11 -1
  38. package/dist/qualify-options.js.map +1 -1
  39. package/dist/qualify-sync.d.ts +18 -1
  40. package/dist/qualify-sync.js +142 -12
  41. package/dist/qualify-sync.js.map +1 -1
  42. package/dist/records-package.d.ts +63 -0
  43. package/dist/records-package.js +300 -0
  44. package/dist/records-package.js.map +1 -0
  45. package/package.json +3 -2
  46. package/src/distributed-records.ts +251 -0
  47. package/src/index.ts +5 -0
  48. package/src/measurement-grid.ts +97 -0
  49. package/src/memory-bound.ts +55 -0
  50. package/src/probe-judge.ts +25 -1
  51. package/src/probe-merge.ts +89 -0
  52. package/src/probe-output.ts +13 -0
  53. package/src/probe-runner.ts +75 -13
  54. package/src/qualification-record.ts +44 -2
  55. package/src/qualify-cli.ts +32 -1
  56. package/src/qualify-declarations.ts +17 -0
  57. package/src/qualify-options.ts +11 -1
  58. package/src/qualify-sync.ts +147 -11
  59. package/src/records-package.ts +333 -0
  60. package/tools/{ai-trace-commit-check.sh → commit-message-check.sh} +16 -16
  61. package/tools/{ai-trace-patterns.json → commit-message-patterns.json} +1 -1
@@ -20,12 +20,22 @@ import { classifyPluginHosting, type QualifiedPlugin } from "./hosting-suitabili
20
20
  import type { SuitabilityRating } from "./rating.js";
21
21
  import type { PluginLoader, PluginTarget } from "./loader.js";
22
22
  import { EXIT_CODE, OUTPUT_FORMATS, QUALIFY_OPTIONS, type OutputFormat } from "./qualify-options.js";
23
+ import { GRID_HOSTS, parseGridSpec, type GridSpec } from "./measurement-grid.js";
24
+ import { parseMemoryLimit } from "./memory-bound.js";
23
25
 
24
26
  export interface QualifyArgs {
25
27
  readonly all: boolean;
26
28
  readonly targets: readonly string[];
27
29
  readonly rates: readonly number[];
28
30
  readonly quanta: readonly number[];
31
+ /** How the grid is chosen (`--grid`, `--grid-hosts`). */
32
+ readonly grid: GridSpec;
33
+ /** `--cells-per-plugin-budget`: at most this many cells a plugin; 0 is every cell. */
34
+ readonly cellsPerPluginBudget: number;
35
+ /** `--blocks`: blocks of the CLAP sweep a cell, when given. */
36
+ readonly blocks?: number;
37
+ /** `--memory-limit`: bytes, or `off`; absent is the default, half of the RAM. */
38
+ readonly memoryLimit?: number | "off";
29
39
  readonly out?: string;
30
40
  /** `--pins`: where the pins file is written. */
31
41
  readonly pins?: string;
@@ -79,6 +89,23 @@ export function parseQualifyArgs(argv: readonly string[]): ParseResult {
79
89
  if (rates === undefined) return { ok: false, error: "--rates must be positive integers, comma-separated" };
80
90
  const quanta = numberList((seen.get("--quanta") as string | undefined) ?? defaultOf("--quanta"));
81
91
  if (quanta === undefined) return { ok: false, error: "--quanta must be positive integers, comma-separated" };
92
+ const gridText = seen.get("--grid") as string | undefined;
93
+ if (gridText !== undefined && seen.has("--grid-hosts") && gridText.trim() !== "hosts") return { ok: false, error: "--grid-hosts and --grid name two grids; give one" };
94
+ const grid = gridText === undefined ? { ok: true as const, spec: GRID_HOSTS } : parseGridSpec(gridText);
95
+ if (!grid.ok) return { ok: false, error: `--grid: ${grid.error}` };
96
+ const count = (flag: string, min: number): number | undefined => {
97
+ const v = seen.get(flag) as string | undefined;
98
+ if (v === undefined) return undefined;
99
+ const n = Number(v);
100
+ return /^\d+$/.test(v.trim()) && Number.isSafeInteger(n) && n >= min ? n : NaN;
101
+ };
102
+ const cellsPerPluginBudget = count("--cells-per-plugin-budget", 0) ?? 0;
103
+ if (Number.isNaN(cellsPerPluginBudget)) return { ok: false, error: "--cells-per-plugin-budget must be a whole number, 0 or more" };
104
+ const blocks = count("--blocks", 1);
105
+ if (blocks !== undefined && Number.isNaN(blocks)) return { ok: false, error: "--blocks must be a whole number, 1 or more" };
106
+ const memoryText = seen.get("--memory-limit") as string | undefined;
107
+ const memory = memoryText === undefined ? undefined : parseMemoryLimit(memoryText);
108
+ if (memory !== undefined && !memory.ok) return { ok: false, error: `--memory-limit: ${memory.error}` };
82
109
  const format = (seen.get("--format") as string | undefined) ?? defaultOf("--format");
83
110
  if (!(OUTPUT_FORMATS as readonly string[]).includes(format)) {
84
111
  return { ok: false, error: `--format must be one of ${OUTPUT_FORMATS.join(", ")}` };
@@ -94,6 +121,10 @@ export function parseQualifyArgs(argv: readonly string[]): ParseResult {
94
121
  targets,
95
122
  rates,
96
123
  quanta,
124
+ grid: grid.spec,
125
+ cellsPerPluginBudget,
126
+ ...(blocks === undefined ? {} : { blocks }),
127
+ ...(memory === undefined ? {} : { memoryLimit: memory.bytes }),
97
128
  ...(out === undefined ? {} : { out }),
98
129
  ...(pins === undefined ? {} : { pins }),
99
130
  format: format as OutputFormat,
@@ -108,7 +139,7 @@ export function helpText(): string {
108
139
  const rows = QUALIFY_OPTIONS.map((o) => {
109
140
  const left = o.value === undefined ? o.flag : `${o.flag} <${o.value}>`;
110
141
  const def = o.default === undefined ? "" : ` (default: ${o.default})`;
111
- return ` ${left.padEnd(28)}${o.describe}${def}`;
142
+ return ` ${left.padEnd(36)}${o.describe}${def}`;
112
143
  });
113
144
  return [
114
145
  "plugin-qualify — qualify audio plugins for real-time hosting",
@@ -133,6 +133,23 @@ export const NATIVE_TOOL_DIRS: readonly string[] = ["/usr/libexec/plugin-qualify
133
133
  export const CLAP_SWEEP_BLOCKS = 512;
134
134
  export const CLAP_SWEEP_FRAMES = 512;
135
135
 
136
+ /**
137
+ * The measurement grid (#193). Every record written before the grid was measured at `GRID_CONTINUITY_QUANTUM`, so
138
+ * the hosts' grid also takes that quantum at every rate: a new record stays comparable with an old one.
139
+ */
140
+ export const GRID_CONTINUITY_QUANTUM = CLAP_SWEEP_FRAMES;
141
+
142
+ /**
143
+ * The memory bound of a run (#193). The runner's data limit (`--data-bytes`, `RLIMIT_DATA` on the measuring child) is
144
+ * `MEMORY_LIMIT_RAM_FRACTION` of the machine's RAM unless `--memory-limit` says otherwise. With `MEMORY_BOUND_EXIT_ENV`
145
+ * set, the interposer ends the child with `MEMORY_BOUND_EXIT_STATUS` the moment an allocation is refused, so a run
146
+ * that outgrew the bound is told from a crash by an exit status, never by words; `native/rt-interpose.h` holds the
147
+ * same two (src/memory-bound.test.ts holds them together).
148
+ */
149
+ export const MEMORY_LIMIT_RAM_FRACTION = 0.5;
150
+ export const MEMORY_BOUND_EXIT_ENV = "OMX_RT_OOM_EXIT";
151
+ export const MEMORY_BOUND_EXIT_STATUS = 121;
152
+
136
153
  /** Seconds one `.clap` may take to qualify at every rate before the prober is killed. */
137
154
  export const CLAP_QUALIFY_TIMEOUT_S = 120;
138
155
 
@@ -4,7 +4,7 @@
4
4
  * The `plugin-qualify` CLI's OPTIONS, declared once. The parser, `--help` and the tests all read this table; every default is a declaration
5
5
  * from `declarations.ts`, never a literal here.
6
6
  */
7
- import { QUALIFY_QUANTA, QUALIFY_RATES } from "./qualify-declarations.js";
7
+ import { CLAP_SWEEP_BLOCKS, QUALIFY_QUANTA, QUALIFY_RATES } from "./qualify-declarations.js";
8
8
  import { HOST_PROFILES, OPENMIXER_CONSOLE_PROFILE } from "./host-profiles.js";
9
9
 
10
10
  /** How the result is printed. `text` = plugin, verdict, the reason that decided it. */
@@ -35,6 +35,16 @@ export const QUALIFY_OPTIONS: readonly QualifyOption[] = [
35
35
  { flag: "--all", describe: "every LV2 plugin on the LV2 search path and every CLAP plugin on the CLAP search path; with --out, one record each" },
36
36
  { flag: "--rates", value: "list", describe: "sample rates to measure at, comma-separated", default: QUALIFY_RATES.join(",") },
37
37
  { flag: "--quanta", value: "list", describe: "quanta (frames) to rate cost at, comma-separated", default: QUALIFY_QUANTA.join(",") },
38
+ {
39
+ flag: "--grid",
40
+ value: "hosts|full|cells",
41
+ describe: "the cells measured: hosts = each judged host's rate/quantum, then 512 at every rate; full = every rate at every quantum; or <rate>/<quantum>,...",
42
+ default: "hosts",
43
+ },
44
+ { flag: "--grid-hosts", describe: "the same as --grid hosts" },
45
+ { flag: "--cells-per-plugin-budget", value: "n", describe: "measure at most the first n cells of the grid a plugin (0: every cell)", default: "0" },
46
+ { flag: "--blocks", value: "n", describe: "blocks of the CLAP sweep a cell (the LV2 pass has a fixed shape)", default: String(CLAP_SWEEP_BLOCKS) },
47
+ { flag: "--memory-limit", value: "size|off", describe: "the memory bound of every run (bytes, or with K, M, G); past it a cell is probe.oom", default: "half of the RAM" },
38
48
  { flag: "--out", value: "dir", describe: "write one interchange JSON document per run into this directory" },
39
49
  { flag: "--format", value: OUTPUT_FORMATS.join("|"), describe: "json = the verdicts as JSON; text = plugin, verdict, deciding reason", default: "text" },
40
50
  { flag: "--isolate", describe: "a fresh worker per plugin, so a crash or hang takes only that plugin" },
@@ -24,6 +24,7 @@ import { homedir, tmpdir } from "node:os";
24
24
  import { basename, dirname, join, resolve } from "node:path";
25
25
 
26
26
  import { clapQualifyTools } from "./clap-qualify.js";
27
+ import { DISTRIBUTED_DIR_SYSTEM, EMPTY_LAYER, applyDistributed, distributedUri, hostClassOfBoard, matchDistributed, readBoardModel, readDistributedLayer, type DistributedLayer } from "./distributed-records.js";
27
28
  import { clapSearchPath, parseClapScan } from "./clap-loader.js";
28
29
  import { HOSTING_CODE } from "./hosting-codes.js";
29
30
  import { shippedHostProfile, type HostProfile } from "./host-profiles.js";
@@ -31,8 +32,10 @@ import { lv2BundlesOnPath, manifestPlugins } from "./lv2-loader.js";
31
32
  import { judgmentPinEntries, pinsFile, type PinEntry } from "./pins-file.js";
32
33
  import type { Judgment } from "./probe-judge.js";
33
34
  import { qualifyTargets } from "./probe-runner.js";
35
+ import { GRID_HOSTS, parseGridSpec, type GridSpec } from "./measurement-grid.js";
36
+ import { memoryBoundOf, parseMemoryLimit, type MemoryBound } from "./memory-bound.js";
34
37
  import { writeQualification, type NotMeasured } from "./qualification-record.js";
35
- import { DESCRIPTOR_DIR_STATE_SUBDIR, DESCRIPTOR_DIR_SYSTEM } from "./qualify-declarations.js";
38
+ import { DESCRIPTOR_DIR_STATE_SUBDIR, DESCRIPTOR_DIR_SYSTEM, QUALIFY_QUANTA, QUALIFY_RATES } from "./qualify-declarations.js";
36
39
  import { toolsDir } from "./tool-paths.js";
37
40
  import { writeFileWhole } from "./whole-file.js";
38
41
 
@@ -64,11 +67,16 @@ export const SYNC_SETTINGS: readonly SyncSetting[] = [
64
67
  { key: "qualification_dir", meaning: "where the qualification records go", default: "/var/lib/omx/qualification (system), else $XDG_STATE_HOME/omx/qualification" },
65
68
  { key: "descriptor_dir", meaning: "where the scanned descriptors go", default: `${DESCRIPTOR_DIR_SYSTEM} (system), else $XDG_STATE_HOME/${DESCRIPTOR_DIR_STATE_SUBDIR}` },
66
69
  { key: "state_file", meaning: "what was qualified, from which bytes, and how it ended", default: "/var/lib/omx/qualify-sync.json (system), else $XDG_STATE_HOME/omx/qualify-sync.json" },
67
- { key: "budget_s", meaning: "the wall clock one plugin gets, in seconds; past it the plugin is recorded as hung", default: "60" },
70
+ { key: "budget_s", meaning: "the wall clock one run gets (one cell of an LV2 plugin, one quantum of a CLAP one), in seconds; past it the plugin is recorded as hung", default: "60" },
68
71
  { key: "host_profile", meaning: "the host the verdict is for (a shipped profile id)", default: "omx-clap-host" },
72
+ { key: "grid", meaning: "the cells measured: hosts (the host's and the console tiers' rate/quantum, then 512 at every rate), full, or <rate>/<quantum>,...", default: "hosts" },
73
+ { key: "cells_per_plugin_budget", meaning: "measure at most the first n cells of the grid a plugin; 0 is every cell", default: "0" },
74
+ { 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" },
69
75
  { key: "hostd_port", meaning: "plugin-hostd's command port the new pins are sent to; 0 sends none", default: "5555" },
70
76
  { key: "hostd_feedback_port", meaning: "plugin-hostd's feedback port, connected before the command port; 0 is the command port's next", default: "0" },
71
77
  { key: "settle_s", meaning: "wait until the plugin directories have been quiet this long before a run", default: "0" },
78
+ { key: "host_class", meaning: "the machine class whose distributed records are read (a directory name)", default: "the board's own from the device tree (rpi5 on a Raspberry Pi 5), else none" },
79
+ { key: "distributed_dir", meaning: "the directories of distributed records, colon-separated, each holding <host class>/<set>/", default: `${DISTRIBUTED_DIR_SYSTEM} (system), else $XDG_DATA_HOME/omx/qualification and each $XDG_DATA_DIRS/omx/qualification` },
72
80
  ];
73
81
 
74
82
  export interface SyncSettings {
@@ -80,9 +88,19 @@ export interface SyncSettings {
80
88
  readonly stateFile: string;
81
89
  readonly budgetS: number;
82
90
  readonly hostProfile: HostProfile;
91
+ /** The cells measured, and at most how many a plugin (0: every cell). */
92
+ readonly grid: GridSpec;
93
+ readonly cellsPerPluginBudget: number;
94
+ /** The memory bound of every run; `undefined` is `off`. */
95
+ readonly memory?: MemoryBound;
83
96
  readonly hostdPort: number;
84
97
  readonly hostdFeedbackPort: number;
85
98
  readonly settleS: number;
99
+ /** The machine class the distributed records are read for; none, and the layer is not read. */
100
+ readonly hostClass?: string;
101
+ readonly distributedDirs: readonly string[];
102
+ /** False under `--no-distributed`. */
103
+ readonly distributed: boolean;
86
104
  }
87
105
 
88
106
  /** A settings file read: one `key value` per line, `#` starts a comment; a key alone is the empty value. An unknown key is refused by line. */
@@ -105,6 +123,7 @@ export interface SyncArgs {
105
123
  readonly config?: string;
106
124
  readonly system: boolean;
107
125
  readonly force: boolean;
126
+ readonly noDistributed: boolean;
108
127
  readonly help: boolean;
109
128
  /** The settings given as flags, by key. */
110
129
  readonly values: ReadonlyMap<string, string>;
@@ -118,11 +137,13 @@ export function parseSyncArgs(argv: readonly string[]): { ok: true; args: SyncAr
118
137
  let config: string | undefined;
119
138
  let system = false;
120
139
  let force = false;
140
+ let noDistributed = false;
121
141
  let help = false;
122
142
  for (let i = 0; i < argv.length; i++) {
123
143
  const a = argv[i]!;
124
144
  if (a === "--system") system = true;
125
145
  else if (a === "--force") force = true;
146
+ else if (a === "--no-distributed") noDistributed = true;
126
147
  else if (a === "--help") help = true;
127
148
  else if (a === "--config" || byFlag.has(a)) {
128
149
  const v = argv[++i];
@@ -131,18 +152,19 @@ export function parseSyncArgs(argv: readonly string[]): { ok: true; args: SyncAr
131
152
  else values.set(byFlag.get(a)!, v);
132
153
  } else return { ok: false, error: `unknown argument ${a}` };
133
154
  }
134
- return { ok: true, args: { ...(config === undefined ? {} : { config }), system, force, help, values } };
155
+ return { ok: true, args: { ...(config === undefined ? {} : { config }), system, force, noDistributed, help, values } };
135
156
  }
136
157
 
137
158
  export function syncHelpText(): string {
138
- const rows = SYNC_SETTINGS.map((s) => ` ${`${flagOf(s.key)} <value>`.padEnd(28)}${s.meaning}${s.default === "" ? "" : ` (default: ${s.default})`}`);
159
+ const rows = SYNC_SETTINGS.map((s) => ` ${`${flagOf(s.key)} <value>`.padEnd(36)}${s.meaning}${s.default === "" ? "" : ` (default: ${s.default})`}`);
139
160
  return [
140
161
  "plugin-qualify-sync: qualify every plugin on the search paths once, and again when its files change",
141
162
  "",
142
- ` --config <file> the settings file (default: ${SYNC_CONF}; a missing file is the defaults)`,
143
- " --system a system service's directories, under /var/lib/omx",
144
- " --force qualify every plugin again, recorded or not",
145
- " --help this text",
163
+ ` ${"--config <file>".padEnd(36)}the settings file (default: ${SYNC_CONF}; a missing file is the defaults)`,
164
+ " --system a system service's directories, under /var/lib/omx",
165
+ " --force qualify every plugin again, recorded or not; no distributed record is read",
166
+ " --no-distributed read no distributed record: every plugin not recorded is measured",
167
+ " --help this text",
146
168
  ...rows,
147
169
  "",
148
170
  "every setting is also a line of the settings file, its key as the first word",
@@ -159,6 +181,7 @@ export function resolveSyncSettings(
159
181
  args: SyncArgs,
160
182
  env: Readonly<Record<string, string | undefined>>,
161
183
  home: string = homedir(),
184
+ boardModel: () => string | undefined = readBoardModel,
162
185
  ): { ok: true; settings: SyncSettings } | { ok: false; error: string } {
163
186
  const get = (key: string): string | undefined => args.values.get(key) ?? file.get(key);
164
187
  const state = env["XDG_STATE_HOME"] !== undefined && env["XDG_STATE_HOME"] !== "" ? env["XDG_STATE_HOME"] : join(home, ".local", "state");
@@ -182,10 +205,31 @@ export function resolveSyncSettings(
182
205
  if (hostdPort === undefined || !Number.isInteger(hostdPort) || hostdPort > 65535) return { ok: false, error: "hostd_port must be a port number, or 0" };
183
206
  if (feedbackPort === undefined || !Number.isInteger(feedbackPort) || feedbackPort > 65535) return { ok: false, error: "hostd_feedback_port must be a port number, or 0" };
184
207
  if (settleS === undefined) return { ok: false, error: "settle_s must be a number of seconds" };
208
+ const gridText = get("grid");
209
+ const grid = gridText === undefined || gridText === "" ? { ok: true as const, spec: GRID_HOSTS } : parseGridSpec(gridText);
210
+ if (!grid.ok) return { ok: false, error: `grid: ${grid.error}` };
211
+ const cellsPerPluginBudget = number("cells_per_plugin_budget", 0);
212
+ if (cellsPerPluginBudget === undefined || !Number.isInteger(cellsPerPluginBudget)) return { ok: false, error: "cells_per_plugin_budget must be a whole number, 0 or more" };
213
+ const memoryText = get("memory_limit");
214
+ const memoryLimit = memoryText === undefined || memoryText === "" ? undefined : parseMemoryLimit(memoryText);
215
+ if (memoryLimit !== undefined && !memoryLimit.ok) return { ok: false, error: `memory_limit: ${memoryLimit.error}` };
216
+ const memory = memoryBoundOf(memoryLimit?.bytes);
185
217
  const profileId = get("host_profile") || "omx-clap-host";
186
218
  const hostProfile = shippedHostProfile(profileId);
187
219
  if (hostProfile === undefined) return { ok: false, error: `host_profile ${profileId} is not a shipped host profile` };
188
220
  const once = (dirs: readonly string[]): string[] => [...new Set(dirs.map((d) => resolve(d)))];
221
+ const dataHome = env["XDG_DATA_HOME"] !== undefined && env["XDG_DATA_HOME"] !== "" ? env["XDG_DATA_HOME"] : join(home, ".local", "share");
222
+ const dataDirs = dirList(env["XDG_DATA_DIRS"] ?? "").length > 0 ? dirList(env["XDG_DATA_DIRS"]!) : ["/usr/local/share", "/usr/share"];
223
+ const distributedNamed = get("distributed_dir");
224
+ const distributedDirs =
225
+ distributedNamed !== undefined && distributedNamed !== ""
226
+ ? dirList(distributedNamed)
227
+ : args.system
228
+ ? [DISTRIBUTED_DIR_SYSTEM]
229
+ : [dataHome, ...dataDirs].map((d) => join(d, "omx", "qualification"));
230
+ const classNamed = get("host_class");
231
+ const hostClass = classNamed !== undefined && classNamed !== "" ? classNamed : hostClassOfBoard(boardModel());
232
+ if (hostClass !== undefined && !/^[A-Za-z0-9][A-Za-z0-9._+-]*$/.test(hostClass)) return { ok: false, error: `host_class ${hostClass} is not a directory name` };
189
233
  return {
190
234
  ok: true,
191
235
  settings: {
@@ -197,9 +241,15 @@ export function resolveSyncSettings(
197
241
  stateFile: place("state_file", "/var/lib/omx/qualify-sync.json", join("omx", "qualify-sync.json")),
198
242
  budgetS,
199
243
  hostProfile,
244
+ grid: grid.spec,
245
+ cellsPerPluginBudget,
246
+ ...(memory === undefined ? {} : { memory }),
200
247
  hostdPort,
201
248
  hostdFeedbackPort: feedbackPort === 0 && hostdPort !== 0 ? hostdPort + 1 : feedbackPort,
202
249
  settleS,
250
+ ...(hostClass === undefined ? {} : { hostClass }),
251
+ distributedDirs: once(distributedDirs),
252
+ distributed: !args.noDistributed,
203
253
  },
204
254
  };
205
255
  }
@@ -390,6 +440,8 @@ export interface SyncEntry {
390
440
  readonly plugins: readonly SyncPlugin[];
391
441
  readonly qualifiedAt: string;
392
442
  readonly seconds: number;
443
+ /** The distributed set the plugins were taken from, when they were not measured here. */
444
+ readonly distributed?: string;
393
445
  }
394
446
 
395
447
  export interface SyncState {
@@ -484,7 +536,17 @@ function judgedPlugins(ctx: SyncContext, runs: readonly { bytes: Buffer; judgmen
484
536
  }
485
537
 
486
538
  function runPass(ctx: SyncContext, target: Parameters<typeof qualifyTargets>[0][number], env: Readonly<Record<string, string | undefined>>) {
487
- return qualifyTargets([target], { rates: [ctx.settings.hostProfile.cost.rate], profiles: [ctx.settings.hostProfile], wallS: ctx.settings.budgetS, env });
539
+ const s = ctx.settings;
540
+ return qualifyTargets([target], {
541
+ rates: QUALIFY_RATES,
542
+ quanta: QUALIFY_QUANTA,
543
+ grid: s.grid,
544
+ cellsPerPluginBudget: s.cellsPerPluginBudget,
545
+ ...(s.memory === undefined ? {} : { memory: s.memory }),
546
+ profiles: [s.hostProfile],
547
+ wallS: s.budgetS,
548
+ env,
549
+ });
488
550
  }
489
551
 
490
552
  /** The descriptors of a scanned `.clap`, through the one descriptor writer; a failure is named and the run goes on. */
@@ -566,6 +628,66 @@ export function qualifyItem(ctx: SyncContext, item: SyncItem, files: readonly Fi
566
628
  return { format: item.format, root: item.root, files: all, plugins, qualifiedAt: new Date(started).toISOString(), seconds: (Date.now() - started) / 1000 };
567
629
  }
568
630
 
631
+ // --- the distributed records ----------------------------------------------------------------------------------------
632
+
633
+ /** The layer a run reads: none under `--force` or `--no-distributed`, or without a host class. */
634
+ function distributedLayerOf(settings: SyncSettings, force: boolean, log: (line: string) => void): DistributedLayer {
635
+ if (force || !settings.distributed) return EMPTY_LAYER;
636
+ if (settings.hostClass === undefined) {
637
+ log("distributed: no host class (host_class unset, and the board names none); no distributed record is read");
638
+ return EMPTY_LAYER;
639
+ }
640
+ const layer = readDistributedLayer(settings.distributedDirs, settings.hostClass, log);
641
+ if (layer.records > 0) log(`distributed: ${String(layer.records)} records of ${layer.sets.join(", ")} for ${settings.hostClass}`);
642
+ return layer;
643
+ }
644
+
645
+ /**
646
+ * The item's entry taken from the distributed records, or `undefined` when it is to be measured: no record finds it,
647
+ * or the ones that do were not taken, each reason said. Its descriptors are written as a measured item's are: a
648
+ * `.clap` is scanned for them (the scan lists the plugins and measures nothing), an LV2 plugin joins the run's
649
+ * descriptor pass.
650
+ */
651
+ function takeDistributed(ctx: SyncContext, layer: DistributedLayer, item: SyncItem, files: readonly FileDigest[]): SyncEntry | undefined {
652
+ if (layer.records === 0) return undefined;
653
+ const started = Date.now();
654
+ const host = ctx.settings.hostProfile.id;
655
+ const match = matchDistributed(layer, item, host, (paths) => digestFiles(item.root, paths, files));
656
+ if (match.status === "none") return undefined;
657
+ if (match.status === "ignored") {
658
+ for (const line of match.lines) ctx.log(line);
659
+ return undefined;
660
+ }
661
+ let plugins: SyncPlugin[];
662
+ try {
663
+ plugins = applyDistributed(ctx.settings.qualificationDir, item, match, host);
664
+ } catch (e) {
665
+ ctx.log(`distributed: ${distributedUri(item, match.records[0]!)}: ${(e as Error).message}; measured here`);
666
+ return undefined;
667
+ }
668
+ if (item.format === "clap") scanClapDescriptors(ctx, item);
669
+ const all = digestFiles(item.root, [...item.files, ...match.files], files) ?? [...files];
670
+ return { format: item.format, root: item.root, files: all, plugins, qualifiedAt: new Date(started).toISOString(), seconds: (Date.now() - started) / 1000, distributed: match.set };
671
+ }
672
+
673
+ /** The descriptors of a `.clap` taken from the layer: the scanner lists its plugins, the descriptor writer writes them. */
674
+ function scanClapDescriptors(ctx: SyncContext, item: SyncItem): void {
675
+ const file = join(item.root, item.files[0]!);
676
+ let tool: string;
677
+ try {
678
+ tool = clapQualifyTools(ctx.env, existsSync).tool;
679
+ } catch (e) {
680
+ ctx.log(`${file}: descriptors not written: ${(e as Error).message}`);
681
+ return;
682
+ }
683
+ const r = spawnSync(tool, ["--json", file], { encoding: "utf8", timeout: Math.max(ctx.settings.budgetS, 35) * 1000, killSignal: "SIGKILL", maxBuffer: 64 << 20 });
684
+ if (r.error !== undefined || r.status !== 0) {
685
+ ctx.log(`${file}: descriptors not written: the scan ended ${r.error?.message ?? `with exit ${String(r.status)}`}`);
686
+ return;
687
+ }
688
+ writeClapDescriptors(ctx, r.stdout);
689
+ }
690
+
569
691
  // --- the outputs ------------------------------------------------------------------------------------------------------
570
692
 
571
693
  /** The pin file's text: every whole pin of the state, the worse verdict kept for a uri pinned twice. */
@@ -754,13 +876,15 @@ export interface SyncSummary {
754
876
  readonly crashed: number;
755
877
  readonly hung: number;
756
878
  readonly unmeasured: number;
879
+ /** The plugins taken from the distributed records, not measured. */
880
+ readonly distributed: number;
757
881
  readonly seconds: number;
758
882
  /** The pins a running daemon took, when one answered. */
759
883
  readonly sent?: number;
760
884
  }
761
885
 
762
886
  export function summaryLine(s: SyncSummary): string {
763
- 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)}, ${s.seconds.toFixed(1)} s`;
887
+ 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.seconds.toFixed(1)} s`;
764
888
  }
765
889
 
766
890
  /** The lock of one state file: a second sync on it while one runs returns at once. A lock whose process is gone is taken over. */
@@ -814,7 +938,8 @@ export async function runSync(settings: SyncSettings, options: SyncRunOptions =
814
938
  const dirs = [...settings.clapDirs, ...settings.lv2Dirs];
815
939
  await waitQuiet(dirs, settings.settleS, log);
816
940
  const ctx: SyncContext = { settings, env, log };
817
- const count = { qualified: 0, skipped: 0, refused: 0, crashed: 0, hung: 0, unmeasured: 0 };
941
+ const count = { qualified: 0, skipped: 0, refused: 0, crashed: 0, hung: 0, unmeasured: 0, distributed: 0 };
942
+ const layer = distributedLayerOf(settings, options.force === true, log);
818
943
  let before: string;
819
944
  try {
820
945
  before = readFileSync(settings.pinFile, "utf8");
@@ -846,6 +971,17 @@ export async function runSync(settings: SyncSettings, options: SyncRunOptions =
846
971
  log(`${item.key}: a file of it cannot be read; skipped this run`);
847
972
  continue;
848
973
  }
974
+ const taken = takeDistributed(ctx, layer, item, files);
975
+ if (taken !== undefined) {
976
+ entries[item.key] = taken;
977
+ if (item.format === "lv2") lv2Pinned.push(...taken.plugins.filter((p) => p.pin !== undefined).map((p) => p.uri));
978
+ count.distributed += taken.plugins.length;
979
+ for (const p of taken.plugins) log(`distributed: ${p.uri}: applied (${taken.distributed!})`);
980
+ state = { schema: SYNC_STATE_SCHEMA, entries };
981
+ writeFileWhole(settings.stateFile, `${JSON.stringify(state, null, 1)}\n`);
982
+ writeFileWhole(settings.pinFile, syncPinsText(state));
983
+ continue;
984
+ }
849
985
  const entry = qualifyItem(ctx, item, files);
850
986
  entries[item.key] = entry;
851
987
  if (item.format === "lv2") lv2Pinned.push(...entry.plugins.filter((p) => p.pin !== undefined).map((p) => p.uri));