@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.
- package/README.md +45 -13
- package/dist/clap-qualify.d.ts +3 -1
- package/dist/clap-qualify.js +7 -1
- package/dist/clap-qualify.js.map +1 -1
- package/dist/direct-limit.d.ts +27 -0
- package/dist/direct-limit.js +52 -0
- package/dist/direct-limit.js.map +1 -0
- package/dist/host-admission.d.ts +10 -2
- package/dist/host-admission.js +13 -2
- package/dist/host-admission.js.map +1 -1
- package/dist/host-profiles.d.ts +8 -0
- package/dist/host-profiles.js +16 -1
- package/dist/host-profiles.js.map +1 -1
- package/dist/hosting-codes.generated.d.ts +10 -0
- package/dist/hosting-codes.generated.js +10 -0
- package/dist/hosting-codes.generated.js.map +1 -1
- package/dist/hosting-suitability.d.ts +34 -8
- package/dist/hosting-suitability.js +92 -7
- package/dist/hosting-suitability.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/measurement/format.d.ts +54 -8
- package/dist/measurement/format.js +54 -3
- package/dist/measurement/format.js.map +1 -1
- package/dist/measurement/plan.d.ts +3 -3
- package/dist/measurement/plan.js +1 -1
- package/dist/measurement-grid.d.ts +8 -0
- package/dist/measurement-grid.js +4 -0
- package/dist/measurement-grid.js.map +1 -1
- package/dist/memory-bound.d.ts +6 -0
- package/dist/memory-bound.js +8 -0
- package/dist/memory-bound.js.map +1 -1
- package/dist/memory-budget.d.ts +39 -0
- package/dist/memory-budget.js +32 -0
- package/dist/memory-budget.js.map +1 -0
- package/dist/probe-judge.js +22 -9
- package/dist/probe-judge.js.map +1 -1
- package/dist/probe-merge.js +6 -5
- package/dist/probe-merge.js.map +1 -1
- package/dist/probe-output-fixtures.d.ts +6 -0
- package/dist/probe-output-fixtures.js +8 -0
- package/dist/probe-output-fixtures.js.map +1 -1
- package/dist/probe-output.d.ts +4 -2
- package/dist/probe-output.js +2 -1
- package/dist/probe-output.js.map +1 -1
- package/dist/probe-runner.js +6 -3
- package/dist/probe-runner.js.map +1 -1
- package/dist/qualification-record.d.ts +26 -2
- package/dist/qualification-record.js +17 -18
- package/dist/qualification-record.js.map +1 -1
- package/dist/qualify-declarations.d.ts +30 -11
- package/dist/qualify-declarations.js +38 -2
- package/dist/qualify-declarations.js.map +1 -1
- package/dist/qualify-sync.js +18 -4
- package/dist/qualify-sync.js.map +1 -1
- package/package.json +1 -1
- package/src/clap-qualify.ts +8 -1
- package/src/direct-limit.ts +68 -0
- package/src/host-admission.ts +15 -2
- package/src/host-profiles.ts +17 -1
- package/src/hosting-codes.generated.ts +10 -0
- package/src/hosting-suitability.ts +105 -11
- package/src/index.ts +1 -0
- package/src/measurement/format.ts +97 -3
- package/src/measurement/plan.ts +3 -3
- package/src/measurement-grid.ts +5 -0
- package/src/memory-bound.ts +10 -0
- package/src/memory-budget.ts +55 -0
- package/src/probe-judge.ts +21 -8
- package/src/probe-merge.ts +6 -5
- package/src/probe-output-fixtures.ts +10 -1
- package/src/probe-output.ts +5 -2
- package/src/probe-runner.ts +5 -3
- package/src/qualification-record.ts +22 -4
- package/src/qualify-declarations.ts +49 -3
- package/src/qualify-sync.ts +18 -3
- package/tools/_declarations.py +1 -0
- package/tools/benchmark.py +25 -2
- package/tools/gen-python-declarations.mjs +1 -0
- package/tools/lv2-measure.mjs +7 -4
- package/tools/lv2_presets.py +1 -1
- package/tools/mod-host-admit.mjs +2 -1
package/src/probe-merge.ts
CHANGED
|
@@ -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
|
-
|
|
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]
|
|
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
|
|
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 ?
|
|
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
|
}
|
package/src/probe-output.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
}
|
package/src/probe-runner.ts
CHANGED
|
@@ -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.
|
|
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
|
-
/**
|
|
63
|
-
|
|
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.
|
|
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
|
|
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
|
|
package/src/qualify-sync.ts
CHANGED
|
@@ -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))
|
|
1171
|
-
|
|
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) {
|
package/tools/_declarations.py
CHANGED
|
@@ -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"
|
package/tools/benchmark.py
CHANGED
|
@@ -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
|
|
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)
|
package/tools/lv2-measure.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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;
|
package/tools/lv2_presets.py
CHANGED
|
@@ -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 (
|
|
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
|
|
package/tools/mod-host-admit.mjs
CHANGED
|
@@ -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
|
-
|
|
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()));
|