@openmixer/plugin-qualify 0.17.5 → 0.18.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 (83) hide show
  1. package/README.md +45 -13
  2. package/dist/clap-qualify.d.ts +3 -1
  3. package/dist/clap-qualify.js +7 -1
  4. package/dist/clap-qualify.js.map +1 -1
  5. package/dist/direct-limit.d.ts +27 -0
  6. package/dist/direct-limit.js +52 -0
  7. package/dist/direct-limit.js.map +1 -0
  8. package/dist/host-admission.d.ts +10 -2
  9. package/dist/host-admission.js +13 -2
  10. package/dist/host-admission.js.map +1 -1
  11. package/dist/host-profiles.d.ts +8 -0
  12. package/dist/host-profiles.js +16 -1
  13. package/dist/host-profiles.js.map +1 -1
  14. package/dist/hosting-codes.generated.d.ts +10 -0
  15. package/dist/hosting-codes.generated.js +10 -0
  16. package/dist/hosting-codes.generated.js.map +1 -1
  17. package/dist/hosting-suitability.d.ts +34 -8
  18. package/dist/hosting-suitability.js +92 -7
  19. package/dist/hosting-suitability.js.map +1 -1
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.js +1 -0
  22. package/dist/index.js.map +1 -1
  23. package/dist/measurement/format.d.ts +54 -8
  24. package/dist/measurement/format.js +54 -3
  25. package/dist/measurement/format.js.map +1 -1
  26. package/dist/measurement/plan.d.ts +3 -3
  27. package/dist/measurement/plan.js +1 -1
  28. package/dist/measurement-grid.d.ts +8 -0
  29. package/dist/measurement-grid.js +4 -0
  30. package/dist/measurement-grid.js.map +1 -1
  31. package/dist/memory-bound.d.ts +6 -0
  32. package/dist/memory-bound.js +8 -0
  33. package/dist/memory-bound.js.map +1 -1
  34. package/dist/memory-budget.d.ts +39 -0
  35. package/dist/memory-budget.js +32 -0
  36. package/dist/memory-budget.js.map +1 -0
  37. package/dist/probe-judge.js +22 -9
  38. package/dist/probe-judge.js.map +1 -1
  39. package/dist/probe-merge.js +6 -5
  40. package/dist/probe-merge.js.map +1 -1
  41. package/dist/probe-output-fixtures.d.ts +6 -0
  42. package/dist/probe-output-fixtures.js +8 -0
  43. package/dist/probe-output-fixtures.js.map +1 -1
  44. package/dist/probe-output.d.ts +4 -2
  45. package/dist/probe-output.js +2 -1
  46. package/dist/probe-output.js.map +1 -1
  47. package/dist/probe-runner.js +6 -3
  48. package/dist/probe-runner.js.map +1 -1
  49. package/dist/qualification-record.d.ts +26 -2
  50. package/dist/qualification-record.js +17 -18
  51. package/dist/qualification-record.js.map +1 -1
  52. package/dist/qualify-declarations.d.ts +30 -11
  53. package/dist/qualify-declarations.js +38 -2
  54. package/dist/qualify-declarations.js.map +1 -1
  55. package/dist/qualify-sync.js +18 -4
  56. package/dist/qualify-sync.js.map +1 -1
  57. package/package.json +1 -1
  58. package/src/clap-qualify.ts +8 -1
  59. package/src/direct-limit.ts +68 -0
  60. package/src/host-admission.ts +15 -2
  61. package/src/host-profiles.ts +17 -1
  62. package/src/hosting-codes.generated.ts +10 -0
  63. package/src/hosting-suitability.ts +105 -11
  64. package/src/index.ts +1 -0
  65. package/src/measurement/format.ts +97 -3
  66. package/src/measurement/plan.ts +3 -3
  67. package/src/measurement-grid.ts +5 -0
  68. package/src/memory-bound.ts +10 -0
  69. package/src/memory-budget.ts +55 -0
  70. package/src/probe-judge.ts +21 -8
  71. package/src/probe-merge.ts +6 -5
  72. package/src/probe-output-fixtures.ts +10 -1
  73. package/src/probe-output.ts +5 -2
  74. package/src/probe-runner.ts +5 -3
  75. package/src/qualification-record.ts +22 -4
  76. package/src/qualify-declarations.ts +49 -3
  77. package/src/qualify-sync.ts +18 -3
  78. package/tools/_declarations.py +1 -0
  79. package/tools/benchmark.py +25 -2
  80. package/tools/gen-python-declarations.mjs +1 -0
  81. package/tools/lv2-measure.mjs +7 -4
  82. package/tools/lv2_presets.py +1 -1
  83. package/tools/mod-host-admit.mjs +2 -1
@@ -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_HOST, 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) admissions[ADAPTER_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
 
@@ -59,8 +61,11 @@ export interface RecordTiers {
59
61
  readonly dimmed?: string;
60
62
  }
61
63
 
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" };
64
+ /**
65
+ * The profile each format's isolated tier is judged under: omx-clap-host for both, an LV2 plugin through plugin-hostd's
66
+ * LV2 adapter, whose run decides that verdict (`host-admission.ts`). mod-host is judged only when a run asks for it.
67
+ */
68
+ export const ISOLATED_TIER_HOST: Readonly<Record<string, string>> = { lv2: "omx-clap-host", clap: "omx-clap-host" };
64
69
  export const IN_PROCESS_TIER_HOST = "openmixer-console";
65
70
 
66
71
  /** 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 +105,26 @@ export interface UnmeasuredLatency {
100
105
  }
101
106
 
102
107
  /** 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"> & {
108
+ export type RecordGridPoint = Omit<ProbeGridPoint, "latency" | "memory"> & {
109
+ /** The footprint as measured, with what it comes to: what a client adding the plugin reads (`memory-budget.ts`). */
110
+ readonly memory?: MemoryFootprint & { readonly firstInstanceKb?: number; readonly perInstanceKb?: number };
104
111
  readonly latency: (Omit<NonNullable<ProbeGridPoint["latency"]>, "measuredFrames"> & { readonly measuredFrames: number }) | UnmeasuredLatency;
105
112
  };
106
113
 
107
114
  const UNMEASURED_WORDS: ReadonlySet<string> = new Set(Object.values(MEASUREMENT_UNMEASURED_REASONS).map((r) => r.word));
108
115
  const word = (key: keyof typeof MEASUREMENT_UNMEASURED_REASONS): string => MEASUREMENT_UNMEASURED_REASONS[key].word;
109
116
 
117
+ /** The same, for the test. */
118
+ export const memoryOfCellForTest = (p: ProbeGridPoint): Pick<RecordGridPoint, "memory"> => memoryOfCell(p);
119
+
120
+ /** 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. */
121
+ function memoryOfCell(p: ProbeGridPoint): Pick<RecordGridPoint, "memory"> {
122
+ if (p.memory === undefined) return {};
123
+ const first = p.memory.unmeasuredReason === undefined ? firstInstanceKb(p.memory) : undefined;
124
+ const per = p.memory.unmeasuredReason === undefined ? perInstanceKb(p.memory) : undefined;
125
+ return { memory: { ...p.memory, ...(first === undefined ? {} : { firstInstanceKb: first }), ...(per === undefined ? {} : { perInstanceKb: per }) } };
126
+ }
127
+
110
128
  /** The unmeasured word of a cell whose run did not end well, by its outcome code. */
111
129
  const OUTCOME_WORD: Readonly<Record<string, string>> = {
112
130
  "probe.crashed": word("probeCrashed"),
@@ -283,7 +301,7 @@ export function buildQualificationRecord(
283
301
  tiers: tiersOf(judgment),
284
302
  cost: judgment.hosts.map((h) => ({ host: h.verdict.host, ...h.cost })),
285
303
  measurements: {
286
- grid: o.grid.map((p) => ({ ...p, latency: latencyOfCell(o, p) })),
304
+ grid: o.grid.map((p) => ({ ...p, ...memoryOfCell(p), latency: latencyOfCell(o, p) })),
287
305
  ...(o.behaviour === undefined ? {} : { behaviour: o.behaviour }),
288
306
  ...(unmeasuredFigures(o).length === 0 ? {} : { unmeasured: unmeasuredFigures(o) }),
289
307
  },
@@ -147,6 +147,12 @@ export const GRID_CONTINUITY_QUANTUM = CLAP_SWEEP_FRAMES;
147
147
  * same two (src/memory-bound.test.ts holds them together).
148
148
  */
149
149
  export const MEMORY_LIMIT_RAM_FRACTION = 0.5;
150
+ /**
151
+ * The address space (`RLIMIT_AS`) of a process plugin-qualify spawns directly, with no runner to give it a cgroup: all the
152
+ * machine's RAM. Address space is what a plugin reserves, not what it touches, so this is the far bound of a direct
153
+ * run, not its budget. Off for a sanitizer build, which reserves far more address space than it uses.
154
+ */
155
+ export const ADDRESS_SPACE_LIMIT_RAM_FRACTION = 1;
150
156
  export const MEMORY_BOUND_EXIT_ENV = "OMX_RT_OOM_EXIT";
151
157
  export const MEMORY_BOUND_EXIT_STATUS = 121;
152
158
 
@@ -208,10 +214,16 @@ export const DESCRIPTOR_CODE_DIR_UNWRITABLE = "qualify.descriptor.dir-unwritable
208
214
 
209
215
  /** Every threshold, so none is baked into the code. */
210
216
  export interface HostingPolicy {
211
- /** Minimum instantiate/destroy lifecycles. The operator's §14.4 ruling: 1000. */
217
+ /** Minimum instantiate/destroy lifecycles. §14.4 sets it: 1000. */
212
218
  readonly cycleFloor: number;
213
219
  /** Minimum continuous soak, seconds. Default 3 h — the operator's longest show. */
214
220
  readonly soakSeconds: number;
221
+ /**
222
+ * The memory growth a soak may show past warm-up, in KB per minute of audio. 10 MB/min ships provisional and is
223
+ * tightened from measurements; a verdict that rests on it says so.
224
+ */
225
+ readonly memoryGrowthKbPerMinCeiling: number;
226
+ readonly memoryGrowthCeilingProvisional: boolean;
215
227
  /** Above this ratio a plugin is spiky: in-process a spike is the whole desk's xrun. */
216
228
  readonly costTailRatioCeiling: number;
217
229
  /** A soak rate whose settled output sits this far under the plugin's own best rate is
@@ -222,7 +234,7 @@ export interface HostingPolicy {
222
234
  }
223
235
 
224
236
  /**
225
- * Defaults. The cycle floor is the operator's ruling of 2026-09-05 (§14.4, option c) and the
237
+ * Defaults. The cycle floor is the decision of 2026-09-05 (§14.4, option c) and the
226
238
  * soak is §3's default show length. The per-instance cost ceiling is the HOST's budget and
227
239
  * lives in {@link HOST_BUDGETS} (plugin-qualify §3a), not here.
228
240
  */
@@ -231,6 +243,8 @@ export const DEFAULT_HOSTING_POLICY: HostingPolicy = {
231
243
  soakSeconds: 3 * 60 * 60,
232
244
  costTailRatioCeiling: 4,
233
245
  soakDeadDropDb: 40,
246
+ memoryGrowthKbPerMinCeiling: 10240,
247
+ memoryGrowthCeilingProvisional: true,
234
248
  };
235
249
 
236
250
  /** What a host spends on one plugin instance — the
@@ -247,10 +261,42 @@ export type { HostBudget };
247
261
  * `omx-clap-host` runs its plugins one JACK client each, called in turn on the server's one cycle,
248
262
  * so it gets mod-host's share and pair.
249
263
  */
264
+ const GIB_KB = 1024 * 1024;
265
+
266
+ /**
267
+ * The desk's memory budget: half of its 30 GiB for the plugins of a console, the rest the engine's and the system's;
268
+ * one instance's share is what the probe bounds a run by, 4 GiB.
269
+ */
270
+ const DESK_MEMORY = { perInstanceKb: 4 * GIB_KB, perConsoleKb: 16 * GIB_KB };
271
+
272
+ /**
273
+ * What a Raspberry Pi gives its plugins' memory, by the board's RAM: all but 1 GiB for the system and Zynthian's own
274
+ * UI, and an instance's share an eighth of that. Provisional until measured on a Pi: the stress runs show a 4 GiB Pi 5 stopping
275
+ * at 400 MB free, and no plugin's footprint has been read there yet.
276
+ */
277
+ const piMemory = (ramGib: number): NonNullable<HostBudget["memory"]> => ({
278
+ perInstanceKb: ((ramGib - 1) * GIB_KB) / 8,
279
+ perConsoleKb: (ramGib - 1) * GIB_KB,
280
+ provisional: true,
281
+ cells: PI_CELLS,
282
+ });
283
+
284
+ /**
285
+ * The cells a Raspberry Pi runs: zynthian-sys's JACK settings, 48000/128 on a Pi 5 with the V5 kit and 48000/256 on a Pi 4 and
286
+ * the other kits, the one a Pi 5 with no kit card also ran its stress and first boot at. The Zynthian benchmark's matrix sweeps
287
+ * more cells; these two are what a Pi is set to.
288
+ */
289
+ const PI_CELLS = [
290
+ { rate: MEASURE_BASE_RATE, quantum: 128 },
291
+ { rate: MEASURE_BASE_RATE, quantum: 256 },
292
+ ];
293
+
250
294
  export const HOST_BUDGETS = {
251
- "openmixer-console": { coreFractionCeiling: 0.05, rate: COST_REFERENCE_RATE, quantum: QUANTUM_FRAMES },
295
+ "openmixer-console": { coreFractionCeiling: 0.05, rate: COST_REFERENCE_RATE, quantum: QUANTUM_FRAMES, memory: DESK_MEMORY },
252
296
  jalv: { coreFractionCeiling: 0.25, rate: MEASURE_BASE_RATE, quantum: 256 },
253
297
  "mod-host": { coreFractionCeiling: 0.05, rate: MEASURE_BASE_RATE, quantum: 128 },
254
298
  "omx-clap-host": { coreFractionCeiling: 0.05, rate: MEASURE_BASE_RATE, quantum: 128 },
299
+ "openmixer-pi-4gb": { coreFractionCeiling: 0.05, rate: MEASURE_BASE_RATE, quantum: 128, memory: piMemory(4) },
300
+ "openmixer-pi-8gb": { coreFractionCeiling: 0.05, rate: MEASURE_BASE_RATE, quantum: 128, memory: piMemory(8) },
255
301
  } as const satisfies Readonly<Record<string, HostBudget>>;
256
302
 
@@ -21,7 +21,7 @@ import { createHash } from "node:crypto";
21
21
  import { closeSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, statSync, symlinkSync, 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";
@@ -1126,6 +1126,15 @@ function addSummaries(a: SyncSummary, b: SyncSummary): SyncSummary {
1126
1126
  };
1127
1127
  }
1128
1128
 
1129
+ /** Whether this run's scan covers an entry: its root lies under one of the run's directories of its own format. */
1130
+ function scannedBy(settings: Pick<SyncSettings, "lv2Dirs" | "clapDirs">, entry: SyncEntry): boolean {
1131
+ const root = resolve(entry.root);
1132
+ return (entry.format === "clap" ? settings.clapDirs : settings.lv2Dirs).some((d) => {
1133
+ const dir = resolve(d);
1134
+ return root === dir || root.startsWith(dir.endsWith(sep) ? dir : dir + sep);
1135
+ });
1136
+ }
1137
+
1129
1138
  /**
1130
1139
  * One run: wait for quiet, qualify what is new or changed, drop what is gone, write the state, the records, the
1131
1140
  * descriptors and the pin file, and send the changed pins to a running daemon. The state and the pin file are written
@@ -1167,8 +1176,14 @@ async function runLocked(settings: SyncSettings, options: SyncRunOptions): Promi
1167
1176
  unreadable = problems.length > 0;
1168
1177
  const found = new Set(items.map((i) => i.key));
1169
1178
  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`);
1179
+ for (const [key, entry] of Object.entries(state.entries)) {
1180
+ if (found.has(key)) entries[key] = entry;
1181
+ // a run scoped to some directories keeps what it did not scan: gone means not found where this run looked
1182
+ else if (!scannedBy(settings, entry)) {
1183
+ entries[key] = entry;
1184
+ if (pass === 0) log(`${key}: not under a directory this run scans; its pin is kept`);
1185
+ } else log(`${key}: gone, its pin is dropped`);
1186
+ }
1172
1187
  for (const item of items) {
1173
1188
  const recorded = recordedAsIs(item, entries[item.key]);
1174
1189
  if (recorded.same && options.force !== true) {
@@ -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
 
@@ -40,7 +40,8 @@ async function connect() {
40
40
  async function main() {
41
41
  if ((await Promise.race([spawnFailed, sleep(200).then(() => undefined)])) === "ENOENT") return { installed: false, steps: [] };
42
42
  const sock = await connect();
43
- if (sock === undefined) return { installed: true, steps: [{ cmd: "add", died: hostEnd ?? "closed" }] };
43
+ // the host never listened (omx-clap-host with no JACK server): the plugin was never loaded
44
+ if (sock === undefined) return { installed: true, started: false, steps: [] };
44
45
  let buffer = "";
45
46
  let closed = false;
46
47
  sock.on("data", (d) => (buffer += d.toString()));