@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
@@ -14,7 +14,7 @@
14
14
  *
15
15
  * One run that ended well and needs nothing added is kept as the runner wrote it, byte for byte.
16
16
  */
17
- import { outOfMemory, PROBE_OOM, type MemoryBound } from "./memory-bound.js";
17
+ import { isOutOfMemory, MEMORY_CGROUP_MECHANISM, outOfMemory, PROBE_MEMORY_LIMIT, PROBE_OOM, type MemoryBound } from "./memory-bound.js";
18
18
  import { cellKey, type RunnerInvocation } from "./measurement-grid.js";
19
19
  import { PROBE_OK, readProbeOutput, type ProbeOutput } from "./probe-output.js";
20
20
 
@@ -36,7 +36,8 @@ type Json = Record<string, unknown>;
36
36
 
37
37
  export function mergeProbeRuns(parts: readonly ProbeRunPart[], bound: MemoryBound | undefined): { bytes: Buffer; output: ProbeOutput } {
38
38
  if (parts.length === 0) throw new ProbeMergeError("no run to merge");
39
- const outcomes = parts.map((p) => (outOfMemory(p.output, bound) ? PROBE_OOM : p.output.run.outcome));
39
+ // the runner's own outcome when it says it (the kernel killed the child at memory.max), else what its exit means under the data limit
40
+ const outcomes = parts.map((p) => (p.output.run.outcome === PROBE_MEMORY_LIMIT ? PROBE_MEMORY_LIMIT : outOfMemory(p.output, bound) ? PROBE_OOM : p.output.run.outcome));
40
41
  const first = parts[0]!;
41
42
  for (const p of parts.slice(1)) {
42
43
  if (p.output.plugin.id !== first.output.plugin.id || p.output.binary.sha256 !== first.output.binary.sha256 || p.output.offer.id !== first.output.offer.id) {
@@ -66,18 +67,18 @@ export function mergeProbeRuns(parts: readonly ProbeRunPart[], bound: MemoryBoun
66
67
  for (const c of p.invocation.cells) {
67
68
  if (seen.has(cellKey(c))) continue;
68
69
  seen.add(cellKey(c));
69
- grid.push({ rate: c.rate, quantum: c.quantum, outcome: outcomes[i], ...(outcomes[i] === PROBE_OOM && bound !== undefined ? { memoryLimit: { bytes: bound.bytes, mechanism: bound.mechanism } } : {}) });
70
+ grid.push({ rate: c.rate, quantum: c.quantum, outcome: outcomes[i], ...(isOutOfMemory(outcomes[i]!) && bound !== undefined ? { memoryLimit: { bytes: bound.bytes, mechanism: outcomes[i] === PROBE_MEMORY_LIMIT ? MEMORY_CGROUP_MECHANISM : bound.mechanism } } : {}) });
70
71
  }
71
72
  }
72
73
  steps.push(...((docs[i]!["steps"] as unknown[] | undefined) ?? []));
73
74
  });
74
- const judged = outcomes.filter((o) => o !== PROBE_OOM);
75
+ const judged = outcomes.filter((o) => !isOutOfMemory(o));
75
76
  const run = merged["run"] as Json;
76
77
  merged["run"] = {
77
78
  ...run,
78
79
  elapsedMs: parts.reduce((sum, p) => sum + p.output.run.elapsedMs, 0),
79
80
  complete: parts.every((p, i) => p.output.run.complete && outcomes[i] === PROBE_OK),
80
- outcome: judged.length === 0 ? PROBE_OOM : (judged.find((o) => o !== PROBE_OK) ?? PROBE_OK),
81
+ outcome: judged.length === 0 ? outcomes[0]! : (judged.find((o) => o !== PROBE_OK) ?? PROBE_OK),
81
82
  parts: parts.map((p, i) => ({ id: p.output.run.id, outcome: outcomes[i], rates: p.invocation.rates, quantum: p.invocation.quantum, elapsedMs: p.output.run.elapsedMs })),
82
83
  };
83
84
  merged["steps"] = steps;
@@ -11,11 +11,20 @@ import { dirname, join } from "node:path";
11
11
  import { fileURLToPath } from "node:url";
12
12
 
13
13
  import type { ClapQualifyLine } from "./clap-qualify.js";
14
- import { OMX_CLAP_HOST_EXTENSIONS } from "./host-profiles.js";
14
+ import { OMX_CLAP_HOST_EXTENSIONS, type HostProfile } from "./host-profiles.js";
15
15
  import { readProbeOutput, type ProbeOutput } from "./probe-output.js";
16
16
 
17
17
  const FIXTURES = join(dirname(fileURLToPath(import.meta.url)), "..", "fixtures", "probe-output");
18
18
 
19
+ /**
20
+ * The same host with no memory budget: for the tests of what a probe output says about everything else, on documents
21
+ * that do not hold a footprint at the host's own cell.
22
+ */
23
+ export function withoutMemoryBudget<T extends HostProfile>(profile: T): T {
24
+ const { memory: _memory, ...cost } = profile.cost;
25
+ return { ...profile, cost };
26
+ }
27
+
19
28
  export function fixtureText(name: string): string {
20
29
  return readFileSync(join(FIXTURES, `${name}.json`), "utf8");
21
30
  }
@@ -20,7 +20,7 @@
20
20
  import { createHash } from "node:crypto";
21
21
 
22
22
  import { finiteNumber, isRecord, nonEmptyString, optional } from "./measurement/guards.js";
23
- import { readVoicesHeld, type VoicesHeld } from "./measurement/format.js";
23
+ import { readMemoryFootprint, readVoicesHeld, type MemoryFootprint, type VoicesHeld } from "./measurement/format.js";
24
24
 
25
25
  export const PROBE_OUTPUT_FORMAT = "plugin-hostd.probe-output";
26
26
  /** The one major this reader knows. A minor of it only adds fields. */
@@ -96,7 +96,9 @@ export interface ProbeGridPoint {
96
96
  readonly cost?: ProbeCost;
97
97
  readonly rtSafety?: ProbeRtSafety;
98
98
  readonly noteSweep?: ProbeNoteSweep;
99
- /** A cell whose run outgrew the memory bound (`probe.oom`): the bound it ran under. */
99
+ /** What one instance holds at this rate and quantum, as the prober read it; absent when it took none. */
100
+ readonly memory?: MemoryFootprint;
101
+ /** A cell whose run outgrew the memory bound (`probe.oom`, `probe.memory-limit`): the bound it ran under. */
100
102
  readonly memoryLimit?: { readonly bytes: number; readonly mechanism: string };
101
103
  }
102
104
 
@@ -310,6 +312,7 @@ function readPoint(v: unknown, i: number): ProbeGridPoint {
310
312
  ...(o["cost"] === undefined ? {} : { cost: readCost(o["cost"], `${where} cost`) }),
311
313
  ...(o["rtSafety"] === undefined ? {} : { rtSafety: readRtSafety(o["rtSafety"], `${where} rtSafety`) }),
312
314
  ...(o["noteSweep"] === undefined ? {} : { noteSweep: readNoteSweep(o["noteSweep"], `${where} noteSweep`) }),
315
+ ...optional("memory", readMemoryFootprint(o["memory"])),
313
316
  ...(o["memoryLimit"] === undefined ? {} : { memoryLimit: readMemoryLimit(o["memoryLimit"], `${where} memoryLimit`) }),
314
317
  };
315
318
  }
@@ -22,10 +22,10 @@ import type { HostProfile } from "./host-profiles.js";
22
22
  import { OPENMIXER_CONSOLE_PROFILE, shippedHostProfile } from "./host-profiles.js";
23
23
  import { IN_PROCESS_TIER_HOST, ISOLATED_TIER_HOST } from "./qualification-record.js";
24
24
  import type { PluginTarget } from "./loader.js";
25
- import { SPAWNED_ADMISSIONS, admitPlugin, admitUnderAdapter, admittedHostOf, type AdmissionRuns, type HostAdmission } from "./host-admission.js";
25
+ import { ADAPTER_RUN_HOSTS, SPAWNED_ADMISSIONS, admitPlugin, admitUnderAdapter, admittedHostOf, type AdmissionRuns, type HostAdmission } from "./host-admission.js";
26
26
  import { judgeProbeOutput, judgmentVerdicts, type Judgment } from "./probe-judge.js";
27
27
  import { readProbeOutput, type ProbeOutput } from "./probe-output.js";
28
- import { budgetCells, gridCells, runnerInvocations, type GridCell, type GridSpec, type RunnerInvocation } from "./measurement-grid.js";
28
+ import { budgetCells, gridCells, hostCells, runnerInvocations, type GridCell, type GridSpec, type RunnerInvocation } from "./measurement-grid.js";
29
29
  import type { MemoryBound } from "./memory-bound.js";
30
30
  import { mergeProbeRuns, ProbeMergeError } from "./probe-merge.js";
31
31
  import type { QualifyVerdict } from "./qualify-cli.js";
@@ -292,12 +292,14 @@ export function qualifyTargets(
292
292
  const cells =
293
293
  options.grid === undefined
294
294
  ? options.cells
295
- : gridCells(options.grid, { rates: options.rates, quanta: options.quanta ?? QUALIFY_QUANTA, hosts: judgedProfiles.map((p) => ({ rate: p.cost.rate, quantum: p.cost.quantum })) });
295
+ : gridCells(options.grid, { rates: options.rates, quanta: options.quanta ?? QUALIFY_QUANTA, hosts: judgedProfiles.flatMap(hostCells) });
296
296
  const measured = cells === undefined ? {} : { cells: budgetCells(cells, options.cellsPerPluginBudget) };
297
297
  // an LV2 plugin is run under each measured host once, whichever probe output it is judged with
298
298
  const admissions: Record<string, HostAdmission> = {};
299
299
  // an LV2 plugin is also run once under plugin-hostd's LV2 adapter, recorded beside the verdicts (#223)
300
300
  const adapter = target.format === "lv2" ? admitUnderAdapter(target.uri, admissionRuns, admissionEnv) : undefined;
301
+ // and that run decides its omx-clap-host verdict, the isolated tier
302
+ if (adapter !== undefined) for (const host of ADAPTER_RUN_HOSTS) admissions[host] = adapter;
301
303
  if (target.format === "lv2") {
302
304
  for (const profile of judgedProfiles) {
303
305
  const host = admittedHostOf(profile);
@@ -13,6 +13,8 @@
13
13
  * Not yet in the record: the fingerprint of the descriptor's controls, since the descriptor is
14
14
  * written by the scan (stage 5) and plugin-qualify does not read one yet.
15
15
  */
16
+ import type { MemoryFootprint } from "./measurement/format.js";
17
+ import { firstInstanceKb, perInstanceKb } from "./memory-budget.js";
16
18
  import { mkdirSync, readFileSync } from "node:fs";
17
19
  import { join } from "node:path";
18
20
 
@@ -41,6 +43,28 @@ export interface RecordVerdict {
41
43
  readonly reasons: readonly RecordReason[];
42
44
  }
43
45
 
46
+ /**
47
+ * An LV2 plugin's run under the adapter. The top is what the judge reads. `--adapter-only` adds the versions of the hosts
48
+ * it ran under, when, and how each cell went.
49
+ */
50
+ export interface AdapterBlock {
51
+ readonly host: string;
52
+ readonly measured: boolean;
53
+ readonly admitted: boolean;
54
+ readonly code?: string;
55
+ readonly params: Readonly<Record<string, string | number>>;
56
+ readonly versions?: Readonly<Record<string, string>>;
57
+ readonly measuredAt?: string;
58
+ readonly cells?: readonly {
59
+ readonly rate: number;
60
+ readonly quantum: number;
61
+ readonly measured: boolean;
62
+ readonly admitted: boolean;
63
+ readonly code?: string;
64
+ readonly params: Readonly<Record<string, string | number>>;
65
+ }[];
66
+ }
67
+
44
68
  /** One tier of the console as a verdict: the host it is judged under and whether the plugin qualified there. */
45
69
  export interface TierVerdict {
46
70
  readonly host: string;
@@ -48,9 +72,15 @@ export interface TierVerdict {
48
72
  readonly qualified: boolean;
49
73
  readonly rating: string;
50
74
  readonly deciding?: string;
75
+ /** The isolated tier of a `conditional` plugin: it is offered, and runs alone in a worker of its own, never grouped. */
76
+ readonly runsAlone?: true;
51
77
  }
52
78
 
53
- /** The console's two tiers (what the console's roster offers): offered where the verdict qualified, dimmed with the reason when neither did. */
79
+ /**
80
+ * The console's two tiers (what the console's roster offers): in-process where its verdict qualified; isolated where its
81
+ * verdict qualified or is `conditional`, which pins `isolated` and runs alone (`runsAlone`); dimmed with the reason when
82
+ * neither. `qualified` stays `rating === "suitable"`, as the interchange's reader holds it.
83
+ */
54
84
  export interface RecordTiers {
55
85
  readonly inProcess?: TierVerdict;
56
86
  readonly isolated?: TierVerdict;
@@ -59,8 +89,11 @@ export interface RecordTiers {
59
89
  readonly dimmed?: string;
60
90
  }
61
91
 
62
- /** The profile each format's isolated tier is judged under. */
63
- export const ISOLATED_TIER_HOST: Readonly<Record<string, string>> = { lv2: "mod-host", clap: "omx-clap-host" };
92
+ /**
93
+ * The profile each format's isolated tier is judged under: omx-clap-host for both, an LV2 plugin through plugin-hostd's
94
+ * LV2 adapter, whose run decides that verdict (`host-admission.ts`). mod-host is judged only when a run asks for it.
95
+ */
96
+ export const ISOLATED_TIER_HOST: Readonly<Record<string, string>> = { lv2: "omx-clap-host", clap: "omx-clap-host" };
64
97
  export const IN_PROCESS_TIER_HOST = "openmixer-console";
65
98
 
66
99
  /** A latency or cost figure the run could not take: which one, at what rate and quantum, and the code that says why. */
@@ -100,13 +133,29 @@ export interface UnmeasuredLatency {
100
133
  }
101
134
 
102
135
  /** A grid point as the record writes it: every cell carries `latency`, the figures or why there are none (#201). */
103
- export type RecordGridPoint = Omit<ProbeGridPoint, "latency"> & {
136
+ export type RecordGridPoint = Omit<ProbeGridPoint, "latency" | "memory"> & {
137
+ /** The footprint as measured, with what it comes to: what a client adding the plugin reads (`memory-budget.ts`). */
138
+ readonly memory?: MemoryFootprint & { readonly firstInstanceKb?: number; readonly perInstanceKb?: number };
104
139
  readonly latency: (Omit<NonNullable<ProbeGridPoint["latency"]>, "measuredFrames"> & { readonly measuredFrames: number }) | UnmeasuredLatency;
105
140
  };
106
141
 
107
142
  const UNMEASURED_WORDS: ReadonlySet<string> = new Set(Object.values(MEASUREMENT_UNMEASURED_REASONS).map((r) => r.word));
108
143
  const word = (key: keyof typeof MEASUREMENT_UNMEASURED_REASONS): string => MEASUREMENT_UNMEASURED_REASONS[key].word;
109
144
 
145
+ /** The same, for the test. */
146
+ export const memoryOfCellForTest = (p: ProbeGridPoint): Pick<RecordGridPoint, "memory"> => memoryOfCell(p);
147
+
148
+ /** The contract's word for a cell whose run ended with this outcome, `probe-error` for one it has no word of. */
149
+ export const unmeasuredWordOf = (outcome: string): string => OUTCOME_WORD[outcome] ?? word("probeError");
150
+
151
+ /** A cell's footprint with what it comes to; only a footprint with no reason beside it comes to anything a client may add up. */
152
+ export function memoryOfCell(p: ProbeGridPoint): Pick<RecordGridPoint, "memory"> {
153
+ if (p.memory === undefined) return {};
154
+ const first = p.memory.unmeasuredReason === undefined ? firstInstanceKb(p.memory) : undefined;
155
+ const per = p.memory.unmeasuredReason === undefined ? perInstanceKb(p.memory) : undefined;
156
+ return { memory: { ...p.memory, ...(first === undefined ? {} : { firstInstanceKb: first }), ...(per === undefined ? {} : { perInstanceKb: per }) } };
157
+ }
158
+
110
159
  /** The unmeasured word of a cell whose run did not end well, by its outcome code. */
111
160
  const OUTCOME_WORD: Readonly<Record<string, string>> = {
112
161
  "probe.crashed": word("probeCrashed"),
@@ -163,6 +212,51 @@ export interface HistoryBlock {
163
212
  readonly verdicts: readonly ({ readonly host: string } & Adjustment)[];
164
213
  }
165
214
 
215
+ /** One measurement a record lacks, and what it keeps from being decided. */
216
+ export interface OwedMeasurement {
217
+ /** `adapter-run`: the plugin under omx-clap-host's LV2 adapter. `host-run`: under the host named. `qualification`: the whole of it. */
218
+ readonly measurement: "adapter-run" | "host-run" | "qualification";
219
+ readonly host?: string;
220
+ readonly code: string;
221
+ readonly detail?: string;
222
+ }
223
+
224
+ /**
225
+ * The verdicts are not the ones of the run that took the figures: `plugin-qualify-sync --rejudge` derived them later, from
226
+ * those figures, under the rules of `by`. `figures` says who took the figures and when; the verdicts claim nothing more.
227
+ */
228
+ export interface RejudgedBlock {
229
+ /** The UTC time the verdicts last changed. */
230
+ readonly at: string;
231
+ /** The plugin-qualify whose rules judged them. */
232
+ readonly by: string;
233
+ readonly figures: {
234
+ readonly tool: Readonly<Record<string, string>>;
235
+ readonly measuredAt: string;
236
+ readonly runId: string;
237
+ /** The footprints, when a memory-only pass took them later than the rest. */
238
+ readonly memory?: { readonly tool: Readonly<Record<string, string>>; readonly measuredAt: string };
239
+ /** The adapter run, when an adapter-only pass took it later than the rest. */
240
+ readonly adapter?: { readonly versions: Readonly<Record<string, string>>; readonly measuredAt: string };
241
+ };
242
+ /** What these verdicts could not decide and left `unknown`; absent when nothing is owed. */
243
+ readonly owes?: readonly OwedMeasurement[];
244
+ }
245
+
246
+ /** The memory-only pass that took this record's footprints: every other figure is older and comes from `tool`. */
247
+ export interface MemoryRunBlock {
248
+ readonly tool: Readonly<Record<string, string>>;
249
+ readonly measuredAt: string;
250
+ readonly runId: string;
251
+ readonly cells: readonly {
252
+ readonly rate: number;
253
+ readonly quantum: number;
254
+ /** How the cell's run ended; its figures are in the grid's point of that cell. */
255
+ readonly outcome: string;
256
+ readonly memoryLimit?: { readonly bytes: number; readonly mechanism: string };
257
+ }[];
258
+ }
259
+
166
260
  export interface QualificationRecord {
167
261
  readonly schema: typeof QUALIFICATION_RECORD_SCHEMA;
168
262
  readonly plugin: { readonly format: string; readonly id: string; readonly name?: string };
@@ -174,7 +268,7 @@ export interface QualificationRecord {
174
268
  * An LV2 plugin run under plugin-hostd's LV2 adapter (omx-clap-host), as under mod-host: admitted, or refused with
175
269
  * its code, or `measured: false` when it was not tried. Beside the verdicts, which it does not change (#223).
176
270
  */
177
- readonly adapter?: { readonly host: string; readonly measured: boolean; readonly admitted: boolean; readonly code?: string; readonly params: Readonly<Record<string, string | number>> };
271
+ readonly adapter?: AdapterBlock;
178
272
  readonly tiers: RecordTiers;
179
273
  /** The cost reasons at each host's rate and quantum, beside the verdicts; an instrument's with the notes it held. */
180
274
  readonly cost: readonly {
@@ -205,6 +299,10 @@ export interface QualificationRecord {
205
299
  readonly history?: readonly HistoryBlock[];
206
300
  /** Where the record came from when it was not measured on this device: the distributed set's provenance, as the set's package wrote it. */
207
301
  readonly provenance?: Readonly<Record<string, unknown>>;
302
+ /** Present when the verdicts were derived later from the figures below, measuring nothing. */
303
+ readonly rejudged?: RejudgedBlock;
304
+ /** Present when a memory-only pass merged footprints into the grid. */
305
+ readonly memoryRun?: MemoryRunBlock;
208
306
  /** The probe output this record judged. */
209
307
  readonly probe: {
210
308
  readonly file: string;
@@ -246,18 +344,32 @@ function tierOf(judgment: Judgment, host: string | undefined): TierVerdict | und
246
344
  export function tiersOf(judgment: Judgment): RecordTiers {
247
345
  const inProcess = tierOf(judgment, IN_PROCESS_TIER_HOST);
248
346
  const isolated = tierOf(judgment, ISOLATED_TIER_HOST[judgment.output.plugin.format]);
347
+ const runsAlone = isolated?.measured === true && isolated.rating === "conditional";
249
348
  const offeredOn: ("in-process" | "isolated")[] = [];
250
349
  if (inProcess?.qualified === true) offeredOn.push("in-process");
251
- if (isolated?.qualified === true) offeredOn.push("isolated");
350
+ if (isolated?.qualified === true || runsAlone) offeredOn.push("isolated");
252
351
  const why = [inProcess, isolated].flatMap((t) => (t === undefined ? [] : [`${t.host}: ${t.deciding ?? t.rating}`]));
253
352
  return {
254
353
  ...(inProcess === undefined ? {} : { inProcess }),
255
- ...(isolated === undefined ? {} : { isolated }),
354
+ ...(isolated === undefined ? {} : { isolated: { ...isolated, ...(runsAlone ? { runsAlone: true as const } : {}) } }),
256
355
  offeredOn,
257
356
  ...(offeredOn.length === 0 ? { dimmed: why.length === 0 ? "no tier was judged" : why.join("; ") } : {}),
258
357
  };
259
358
  }
260
359
 
360
+ /** A judgment's verdicts as the record writes them. */
361
+ export const verdictRows = (judgment: Judgment): RecordVerdict[] =>
362
+ judgment.hosts.map((h) => ({
363
+ host: h.verdict.host,
364
+ measured: h.verdict.measured,
365
+ rating: h.verdict.rating,
366
+ qualified: h.verdict.qualified,
367
+ hosting: h.hosting,
368
+ arm: h.arm,
369
+ ...(h.verdict.deciding === undefined ? {} : { deciding: h.verdict.deciding }),
370
+ reasons: h.reasons,
371
+ }));
372
+
261
373
  export function buildQualificationRecord(
262
374
  judgment: Judgment,
263
375
  copy: { file: string; sha256: string; bytes: number },
@@ -269,21 +381,12 @@ export function buildQualificationRecord(
269
381
  plugin: { format: o.plugin.format, id: o.plugin.id, ...(o.plugin.name === undefined ? {} : { name: o.plugin.name }) },
270
382
  ...(o.plugin.version === undefined ? {} : { testedVersion: o.plugin.version }),
271
383
  binary: o.binary,
272
- verdicts: judgment.hosts.map((h) => ({
273
- host: h.verdict.host,
274
- measured: h.verdict.measured,
275
- rating: h.verdict.rating,
276
- qualified: h.verdict.qualified,
277
- hosting: h.hosting,
278
- arm: h.arm,
279
- ...(h.verdict.deciding === undefined ? {} : { deciding: h.verdict.deciding }),
280
- reasons: h.reasons,
281
- })),
384
+ verdicts: verdictRows(judgment),
282
385
  ...(judgment.adapter === undefined ? {} : { adapter: judgment.adapter }),
283
386
  tiers: tiersOf(judgment),
284
387
  cost: judgment.hosts.map((h) => ({ host: h.verdict.host, ...h.cost })),
285
388
  measurements: {
286
- grid: o.grid.map((p) => ({ ...p, latency: latencyOfCell(o, p) })),
389
+ grid: o.grid.map((p) => ({ ...p, ...memoryOfCell(p), latency: latencyOfCell(o, p) })),
287
390
  ...(o.behaviour === undefined ? {} : { behaviour: o.behaviour }),
288
391
  ...(unmeasuredFigures(o).length === 0 ? {} : { unmeasured: unmeasuredFigures(o) }),
289
392
  },
@@ -0,0 +1,278 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ /**
4
+ * `plugin-qualify-sync --adapter-only`: an LV2 plugin's verdict on omx-clap-host is decided by its run under plugin-hostd's
5
+ * LV2 adapter, and a record without one owes it (`qualify-rejudge.ts`). This pass makes only that run, for each plugin, at
6
+ * each cell a Pi runs, and puts the block in the record; every other figure stays as it was, and `--rejudge` reads it.
7
+ *
8
+ * Each cell has a private JACK server of its own (`jackd -d dummy`, the rate and quantum on its command line), so that the
9
+ * run can never reach the device's live server (plugin-qualify#187), and each run a fresh host, its time limited by
10
+ * `budget_s` a command and its address space by the limit of a direct spawn. A crash or a timeout is the plugin's recorded
11
+ * outcome. A pass does not start without the versions of the hosts it runs, or without a private server: nothing it could
12
+ * write would say what it measured. A plugin is finished when its block was taken at these host versions at every cell,
13
+ * so a pass cut short can be run again; a host that could not be run decides nothing, and that plugin is tried again.
14
+ */
15
+ import { spawn, spawnSync, type ChildProcess } from "node:child_process";
16
+ import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
17
+ import { tmpdir } from "node:os";
18
+ import { basename, join } from "node:path";
19
+
20
+ import { ADAPTER_HOST, SPAWNED_ADMISSIONS, admitUnderAdapter, type AdmissionRuns, type HostAdmission } from "./host-admission.js";
21
+ import { HOSTD_ENV } from "./hostd-pin-files.js";
22
+ import { cellKey, type GridCell } from "./measurement-grid.js";
23
+ import { defaultAddressSpaceLimit } from "./direct-limit.js";
24
+ import { PROBE_OK } from "./probe-output.js";
25
+ import { QUALIFICATION_RECORD_SCHEMA, type AdapterBlock, type QualificationRecord } from "./qualification-record.js";
26
+ import { ADMIT_ADDRESS_KB_ENV, ADMIT_STATE_DIR_ENV, ADMIT_STEP_MS_ENV, PI_CELLS } from "./qualify-declarations.js";
27
+ import { digestFiles, type SyncContext, type SyncState } from "./qualify-sync.js";
28
+ import { writeFileWhole } from "./whole-file.js";
29
+
30
+ /** The cells the pass runs at: the ones a Pi runs. */
31
+ export const ADAPTER_PASS_CELLS: readonly GridCell[] = PI_CELLS;
32
+
33
+ /** The code of a cell no private server could be had for: the run did not happen. */
34
+ export const NO_PRIVATE_SERVER = `${ADAPTER_HOST}.no-private-server`;
35
+
36
+ const RECORD_SUFFIX = ".qualification.json";
37
+ const SERVER_WAIT_MS = 5000;
38
+ const SERVER_SETTLE_MS = 300;
39
+ const SERVER_STOP_MS = 2000;
40
+
41
+ /** What a program answers to `--version`: its last word on the line that says `version`. */
42
+ export function parseVersion(text: string): string | undefined {
43
+ return /version:?\s+(\S+)/.exec(text)?.[1];
44
+ }
45
+
46
+ export type VersionRun = (tool: string) => { readonly status: number | null; readonly stdout: string };
47
+
48
+ const spawnVersion: VersionRun = (tool) => {
49
+ const r = spawnSync(tool, ["--version"], { encoding: "utf8", timeout: 10000, killSignal: "SIGKILL" });
50
+ return { status: r.status, stdout: `${r.stdout ?? ""}\n${r.stderr ?? ""}` };
51
+ };
52
+
53
+ /** The versions of the hosts as installed now, or which of them could not say. */
54
+ export function hostVersions(env: Readonly<Record<string, string | undefined>>, run: VersionRun = spawnVersion): { ok: true; versions: Readonly<Record<string, string>> } | { ok: false; error: string } {
55
+ const versions: Record<string, string> = {};
56
+ for (const [name, tool] of [[ADAPTER_HOST, ADAPTER_HOST], ["plugin-hostd", env[HOSTD_ENV] ?? "plugin-hostd"]] as const) {
57
+ const r = run(tool);
58
+ const version = r.status === 0 ? parseVersion(r.stdout) : undefined;
59
+ if (version === undefined) return { ok: false, error: `${tool} --version names no version, so a run could not say which host it was under` };
60
+ versions[name] = version;
61
+ }
62
+ return { ok: true, versions };
63
+ }
64
+
65
+ /** A private JACK server for one cell, for as long as a pass needs it. */
66
+ export interface PrivateJack {
67
+ readonly name: string;
68
+ alive(): boolean;
69
+ stop(): void;
70
+ }
71
+
72
+ export type JackStarter = (cell: GridCell) => PrivateJack | undefined;
73
+
74
+ const sleepSync = (ms: number): void => {
75
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
76
+ };
77
+
78
+ /** `jackd` on a name of its own and the dummy driver, at the cell's rate and quantum; `undefined` when none came up. */
79
+ export const startPrivateJack: JackStarter = (cell) => {
80
+ const name = `omx-qualify-${String(process.pid)}-${String(cell.quantum)}`;
81
+ let child: ChildProcess;
82
+ let ended = false;
83
+ try {
84
+ child = spawn("jackd", ["-r", "-n", name, "-d", "dummy", "-r", String(cell.rate), "-p", String(cell.quantum)], { stdio: "ignore", detached: true });
85
+ } catch {
86
+ return undefined;
87
+ }
88
+ child.on("error", () => (ended = true));
89
+ child.on("exit", () => (ended = true));
90
+ child.unref();
91
+ const running = (): boolean => {
92
+ if (ended || child.pid === undefined) return false;
93
+ // the exit event needs the loop this synchronous pass holds: the process table says
94
+ return existsSync(`/proc/${String(child.pid)}`) && !/^\S+ \(.*\) Z/.test(readStat(child.pid));
95
+ };
96
+ // a TERM first: jackd frees the shared memory it holds under /dev/shm itself, which a KILL would leave behind for good
97
+ const signal = (sig: NodeJS.Signals): void => {
98
+ try {
99
+ if (child.pid !== undefined) process.kill(-child.pid, sig);
100
+ } catch {
101
+ // already gone
102
+ }
103
+ };
104
+ const stop = (): void => {
105
+ signal("SIGTERM");
106
+ const until = Date.now() + SERVER_STOP_MS;
107
+ while (running() && Date.now() < until) sleepSync(20);
108
+ if (running()) signal("SIGKILL");
109
+ for (const d of serverDirs(name)) rmSync(d, { recursive: true, force: true });
110
+ };
111
+ const until = Date.now() + SERVER_WAIT_MS;
112
+ while (Date.now() < until) {
113
+ if (child.pid === undefined || !running()) {
114
+ stop();
115
+ return undefined;
116
+ }
117
+ if (serverDirs(name).length > 0) {
118
+ sleepSync(SERVER_SETTLE_MS);
119
+ return { name, alive: running, stop };
120
+ }
121
+ sleepSync(50);
122
+ }
123
+ stop();
124
+ return undefined;
125
+ };
126
+
127
+ const readStat = (pid: number): string => {
128
+ try {
129
+ return readFileSync(`/proc/${String(pid)}/stat`, "utf8");
130
+ } catch {
131
+ return "";
132
+ }
133
+ };
134
+
135
+ /** Where jackd keeps a server's sockets, and its semaphores: the socket appears as the server opens it. */
136
+ function serverDirs(name: string): string[] {
137
+ try {
138
+ return readdirSync("/dev/shm").filter((d) => d.startsWith(`jack_${name}_`) || new RegExp(`^jack_sem\\.\\d+_${name}_`).test(d)).map((d) => join("/dev/shm", d));
139
+ } catch {
140
+ return [];
141
+ }
142
+ }
143
+
144
+ /** Whether a machine can give a pass its private server at all: the program is there. */
145
+ function jackdInstalled(env: Readonly<Record<string, string | undefined>>): boolean {
146
+ return (env["PATH"] ?? process.env["PATH"] ?? "").split(":").some((d) => d !== "" && existsSync(join(d, "jackd")));
147
+ }
148
+
149
+ // --- the block --------------------------------------------------------------------------------------------------------
150
+
151
+ /**
152
+ * The runs of every cell as one block. Its top is what the judge reads: a refusal or a crash at any cell decides, with the
153
+ * cell it happened at, whatever the others say; then a cell where the host could not be run leaves it not measured, so
154
+ * still owed; only every cell admitted is admitted. Pure.
155
+ */
156
+ export function adapterBlockOf(runs: readonly { readonly cell: GridCell; readonly run: HostAdmission }[], versions: Readonly<Record<string, string>>, measuredAt: string): AdapterBlock {
157
+ const cells = runs.map(({ cell, run }) => ({ rate: cell.rate, quantum: cell.quantum, measured: run.measured, admitted: run.admitted, ...(run.code === undefined ? {} : { code: run.code }), params: run.params }));
158
+ const worst = cells.find((c) => c.measured && !c.admitted) ?? cells.find((c) => !c.measured);
159
+ const top =
160
+ worst === undefined
161
+ ? { measured: true, admitted: true, params: {} }
162
+ : { measured: worst.measured, admitted: false, ...(worst.code === undefined ? {} : { code: worst.code }), params: { ...worst.params, rate: worst.rate, quantum: worst.quantum } };
163
+ return { host: ADAPTER_HOST, ...top, versions, measuredAt, cells };
164
+ }
165
+
166
+ /** The record with the block, and every other byte of it as it was. Pure. */
167
+ export const mergeAdapterRun = (record: QualificationRecord, block: AdapterBlock): QualificationRecord => ({ ...record, adapter: block });
168
+
169
+ /** Whether a record's adapter run is done: at these host versions, measured, at every cell. */
170
+ export function adapterFinished(record: QualificationRecord, versions: Readonly<Record<string, string>>, cells: readonly GridCell[] = ADAPTER_PASS_CELLS): boolean {
171
+ const a = record.adapter;
172
+ if (a === undefined || !a.measured || a.versions === undefined || a.cells === undefined) return false;
173
+ const same = Object.keys(versions).length === Object.keys(a.versions).length && Object.entries(versions).every(([k, v]) => a.versions![k] === v);
174
+ return same && cells.every((c) => a.cells!.some((x) => cellKey(x) === cellKey(c)));
175
+ }
176
+
177
+ // --- the pass ---------------------------------------------------------------------------------------------------------
178
+
179
+ export interface AdapterPassOptions {
180
+ readonly force?: boolean;
181
+ readonly runs?: AdmissionRuns;
182
+ readonly jack?: JackStarter;
183
+ readonly versions?: VersionRun;
184
+ /** The test's stand-in for jackd says it is there; the real pass looks. */
185
+ readonly jackdPresent?: boolean;
186
+ readonly now?: () => string;
187
+ }
188
+
189
+ export interface AdapterPassResult {
190
+ readonly measured: number;
191
+ readonly already: number;
192
+ readonly failed: number;
193
+ readonly versions: Readonly<Record<string, string>>;
194
+ }
195
+
196
+ /** The pass could not start: nothing was run and nothing written. */
197
+ export class AdapterPassRefused extends Error {}
198
+
199
+ const readRecord = (path: string): QualificationRecord | undefined => {
200
+ try {
201
+ const r = JSON.parse(readFileSync(path, "utf8")) as QualificationRecord;
202
+ return r.schema === QUALIFICATION_RECORD_SCHEMA ? r : undefined;
203
+ } catch {
204
+ return undefined;
205
+ }
206
+ };
207
+
208
+ /**
209
+ * The run of each LV2 plugin of the state whose probe ended well (one that did not has no verdict a run could decide), at
210
+ * each cell. Each record is written whole as its plugin ends.
211
+ */
212
+ export function adapterPass(ctx: SyncContext, state: SyncState, options: AdapterPassOptions = {}): AdapterPassResult {
213
+ const read = hostVersions(ctx.env, options.versions);
214
+ if (!read.ok) throw new AdapterPassRefused(read.error);
215
+ if (!(options.jackdPresent ?? jackdInstalled(ctx.env))) throw new AdapterPassRefused(`no jackd on the path: ${NO_PRIVATE_SERVER}, and the device's own JACK server is never used for this`);
216
+ const { versions } = read;
217
+ const starter = options.jack ?? startPrivateJack;
218
+ const runs = options.runs ?? SPAWNED_ADMISSIONS;
219
+ const now = options.now ?? (() => new Date().toISOString());
220
+ const servers = new Map<string, PrivateJack>();
221
+ const serverFor = (cell: GridCell): PrivateJack | undefined => {
222
+ const key = cellKey(cell);
223
+ const had = servers.get(key);
224
+ if (had?.alive() === true) return had;
225
+ had?.stop();
226
+ servers.delete(key);
227
+ const fresh = starter(cell);
228
+ if (fresh !== undefined) servers.set(key, fresh);
229
+ return fresh;
230
+ };
231
+ const count = { measured: 0, already: 0, failed: 0 };
232
+ const limit = { [ADMIT_STEP_MS_ENV]: String(ctx.settings.budgetS * 1000), [ADMIT_ADDRESS_KB_ENV]: String(Math.ceil(defaultAddressSpaceLimit() / 2 ** 10)) };
233
+ try {
234
+ for (const [key, entry] of Object.entries(state.entries)) {
235
+ if (entry.format !== "lv2") continue;
236
+ const files = digestFiles(entry.root, entry.files.map((f) => f.path), entry.files);
237
+ if (files === undefined || !files.every((f) => entry.files.some((x) => x.path === f.path && x.sha256 === f.sha256))) {
238
+ ctx.log(`${key}: its files changed since it was measured; the sync measures it whole`);
239
+ continue;
240
+ }
241
+ for (const plugin of entry.plugins) {
242
+ if (plugin.record === undefined || !plugin.record.endsWith(RECORD_SUFFIX)) continue;
243
+ const path = join(ctx.settings.qualificationDir, basename(plugin.record));
244
+ const record = readRecord(path);
245
+ if (record === undefined) {
246
+ ctx.log(`${plugin.uri}: its record does not read; no adapter run`);
247
+ count.failed++;
248
+ continue;
249
+ }
250
+ if (record.probe.outcome !== PROBE_OK) continue;
251
+ if (options.force !== true && adapterFinished(record, versions)) {
252
+ count.already++;
253
+ continue;
254
+ }
255
+ const started = Date.now();
256
+ const done = ADAPTER_PASS_CELLS.map((cell) => {
257
+ const server = serverFor(cell);
258
+ if (server === undefined) return { cell, run: { host: ADAPTER_HOST, measured: false, admitted: false, code: NO_PRIVATE_SERVER, params: {} } as HostAdmission };
259
+ const stateDir = mkdtempSync(join(tmpdir(), "plugin-qualify-adapter-state-"));
260
+ try {
261
+ const env = { ...ctx.env, ...limit, [ADMIT_STATE_DIR_ENV]: stateDir, LV2_PATH: ctx.settings.lv2Dirs.join(":"), JACK_DEFAULT_SERVER: server.name, JACK_NO_START_SERVER: "1" };
262
+ return { cell, run: admitUnderAdapter(record.plugin.id, runs, env) ?? ({ host: ADAPTER_HOST, measured: false, admitted: false, code: `${ADAPTER_HOST}.admission-failed`, params: {} } as HostAdmission) };
263
+ } finally {
264
+ rmSync(stateDir, { recursive: true, force: true });
265
+ }
266
+ });
267
+ const block = adapterBlockOf(done, versions, now());
268
+ writeFileWhole(path, `${JSON.stringify(mergeAdapterRun(record, block), null, 2)}\n`);
269
+ if (block.measured) count.measured++;
270
+ else count.failed++;
271
+ ctx.log(`${plugin.uri}: adapter ${block.measured ? (block.admitted ? "admitted" : `refused (${block.code ?? ""})`) : `not run (${block.code ?? ""})`} in ${((Date.now() - started) / 1000).toFixed(1)} s`);
272
+ }
273
+ }
274
+ } finally {
275
+ for (const s of servers.values()) s.stop();
276
+ }
277
+ return { ...count, versions };
278
+ }