@openmixer/plugin-qualify 0.17.4 → 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 +53 -15
- package/bin/plugin-qualify-sync.mjs +1 -1
- 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.d.ts +20 -0
- package/dist/qualify-sync.js +101 -10
- 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 +103 -9
- 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/measurement-grid.ts
CHANGED
|
@@ -72,6 +72,11 @@ export function gridCells(
|
|
|
72
72
|
return unique([...lead.filter((c) => inProduct.has(cellKey(c))), ...product]);
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/** The cells a host is judged at: the one its cost is read at, then the others its memory budget says it runs. */
|
|
76
|
+
export function hostCells(profile: { readonly cost: GridCell & { readonly memory?: { readonly cells?: readonly GridCell[] } } }): GridCell[] {
|
|
77
|
+
return unique([{ rate: profile.cost.rate, quantum: profile.cost.quantum }, ...(profile.cost.memory?.cells ?? [])]);
|
|
78
|
+
}
|
|
79
|
+
|
|
75
80
|
/** The first `budget` cells, or every one when the budget is absent or 0. */
|
|
76
81
|
export function budgetCells(cells: readonly GridCell[], budget: number | undefined): GridCell[] {
|
|
77
82
|
return budget === undefined || budget === 0 ? [...cells] : cells.slice(0, budget);
|
package/src/memory-bound.ts
CHANGED
|
@@ -17,9 +17,18 @@ import { MEMORY_BOUND_EXIT_STATUS, MEMORY_LIMIT_RAM_FRACTION } from "./qualify-d
|
|
|
17
17
|
/** How the bound is applied: the runner's data limit on the child. */
|
|
18
18
|
export const MEMORY_BOUND_MECHANISM = "rlimit-data";
|
|
19
19
|
|
|
20
|
+
/** How the bound was applied when the runner set it as its cgroup's `memory.max`, which its document says. */
|
|
21
|
+
export const MEMORY_CGROUP_MECHANISM = "cgroup-memory-max";
|
|
22
|
+
|
|
20
23
|
/** The outcome of a run, and of each of its cells, that outgrew the bound. */
|
|
21
24
|
export const PROBE_OOM = "probe.oom";
|
|
22
25
|
|
|
26
|
+
/** The runner's own outcome for a child the kernel killed at its cgroup's `memory.max`. */
|
|
27
|
+
export const PROBE_MEMORY_LIMIT = "probe.memory-limit";
|
|
28
|
+
|
|
29
|
+
/** Whether an outcome says a run outgrew its memory limit, by either bound. */
|
|
30
|
+
export const isOutOfMemory = (outcome: string): boolean => outcome === PROBE_OOM || outcome === PROBE_MEMORY_LIMIT;
|
|
31
|
+
|
|
23
32
|
export interface MemoryBound {
|
|
24
33
|
readonly bytes: number;
|
|
25
34
|
readonly mechanism: typeof MEMORY_BOUND_MECHANISM;
|
|
@@ -50,6 +59,7 @@ export function memoryBoundOf(setting: number | "off" | undefined, ramBytes?: nu
|
|
|
50
59
|
|
|
51
60
|
/** Whether a run under `bound` ended out of memory: the interposer's exit status, or a SIGKILL that was no deadline's. */
|
|
52
61
|
export function outOfMemory(output: ProbeOutput, bound: MemoryBound | undefined): boolean {
|
|
62
|
+
if (output.run.outcome === PROBE_MEMORY_LIMIT || output.steps.some((s) => s.outcome === PROBE_MEMORY_LIMIT)) return true;
|
|
53
63
|
if (bound === undefined) return false;
|
|
54
64
|
return output.steps.some((s) => s.exitStatus === MEMORY_BOUND_EXIT_STATUS || (s.signal === "SIGKILL" && s.outcome === "probe.crashed"));
|
|
55
65
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
// Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
|
|
3
|
+
/**
|
|
4
|
+
* What a measured footprint comes to, and whether it fits a host's budget. Pure: the readings come from the measurers, the
|
|
5
|
+
* budget from the host profile, and the verdict's reasons from `hosting-suitability.ts`.
|
|
6
|
+
*
|
|
7
|
+
* Two figures come out of a footprint. `firstInstanceKb` is the most one instance held at any step, with everything its
|
|
8
|
+
* instances share. `perInstanceKb` is what one more instance costs: the second instance's own part and its voices. A host's
|
|
9
|
+
* `perInstanceKb` bounds the second and its `perConsoleKb` the first, so a sampler with a large library shared between
|
|
10
|
+
* instances can fit a small host once and still be read correctly for two.
|
|
11
|
+
*
|
|
12
|
+
* Nothing absent reads as fitting: no figure, or a reason beside the figures, is `unmeasured`.
|
|
13
|
+
*/
|
|
14
|
+
import type { HostMemoryBudget, MemoryFootprint } from "./measurement/format.js";
|
|
15
|
+
|
|
16
|
+
/** A plugin's footprints as a verdict reads them: per rate, or per rate and quantum (a probe output's grid). */
|
|
17
|
+
export interface MemoryReadings {
|
|
18
|
+
readonly perRate?: Readonly<Record<string, MemoryFootprint>>;
|
|
19
|
+
readonly perRateQuantum?: Readonly<Record<string, MemoryFootprint>>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The most one instance held at any step, with everything its instances share. `undefined` for no figure. */
|
|
23
|
+
export function firstInstanceKb(f: MemoryFootprint): number | undefined {
|
|
24
|
+
const figures = [f.afterInstantiateKb, f.afterActivateKb, f.atRestKb, f.notesHeldKb].filter((v): v is number => v !== undefined);
|
|
25
|
+
return figures.length === 0 ? undefined : Math.max(...figures);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* What one more instance costs: the second instance's own part and the voices of this one. With no second instance
|
|
30
|
+
* measured, the whole of the first, since nothing separates what it shares.
|
|
31
|
+
*/
|
|
32
|
+
export function perInstanceKb(f: MemoryFootprint): number | undefined {
|
|
33
|
+
const first = firstInstanceKb(f);
|
|
34
|
+
if (first === undefined) return undefined;
|
|
35
|
+
if (f.secondInstanceKb === undefined) return first;
|
|
36
|
+
const voices = f.notesHeldKb !== undefined && f.atRestKb !== undefined ? Math.max(f.notesHeldKb - f.atRestKb, 0) : 0;
|
|
37
|
+
return f.secondInstanceKb + voices;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type MemoryBudgetRead =
|
|
41
|
+
| { readonly outcome: "within"; readonly perInstanceKb: number; readonly firstInstanceKb: number }
|
|
42
|
+
| { readonly outcome: "above"; readonly figure: "perInstance" | "firstInstance"; readonly kb: number; readonly ceilingKb: number }
|
|
43
|
+
| { readonly outcome: "unmeasured"; readonly unmeasuredReason?: string };
|
|
44
|
+
|
|
45
|
+
/** A footprint against a budget. A missing figure is `"unmeasured"`, never a fit; a figure above a ceiling is above it whatever else is missing. */
|
|
46
|
+
export function memoryWithinBudget(footprint: MemoryFootprint | undefined, budget: HostMemoryBudget): MemoryBudgetRead {
|
|
47
|
+
const first = footprint === undefined ? undefined : firstInstanceKb(footprint);
|
|
48
|
+
const per = footprint === undefined ? undefined : perInstanceKb(footprint);
|
|
49
|
+
if (first !== undefined && first > budget.perConsoleKb) return { outcome: "above", figure: "firstInstance", kb: first, ceilingKb: budget.perConsoleKb };
|
|
50
|
+
if (per !== undefined && per > budget.perInstanceKb) return { outcome: "above", figure: "perInstance", kb: per, ceilingKb: budget.perInstanceKb };
|
|
51
|
+
if (first === undefined || per === undefined || footprint?.unmeasuredReason !== undefined) {
|
|
52
|
+
return { outcome: "unmeasured", ...(footprint?.unmeasuredReason === undefined ? {} : { unmeasuredReason: footprint.unmeasuredReason }) };
|
|
53
|
+
}
|
|
54
|
+
return { outcome: "within", perInstanceKb: per, firstInstanceKb: first };
|
|
55
|
+
}
|
package/src/probe-judge.ts
CHANGED
|
@@ -24,21 +24,23 @@ import {
|
|
|
24
24
|
type ClapRtSafetyReading,
|
|
25
25
|
type RtSafetyDocument,
|
|
26
26
|
} from "./clap-qualify.js";
|
|
27
|
-
import { gridCostReadings } from "./cost-fields.js";
|
|
27
|
+
import { costPointKey, gridCostReadings } from "./cost-fields.js";
|
|
28
28
|
import {
|
|
29
29
|
hostingCostReasons,
|
|
30
|
+
hostingMemoryReasons,
|
|
30
31
|
noteSweepReasons,
|
|
31
32
|
rtSafetyReasons,
|
|
32
33
|
contributedHostingRating,
|
|
33
34
|
type HostingReason,
|
|
34
35
|
} from "./hosting-suitability.js";
|
|
35
36
|
import type { HostProfile } from "./host-profiles.js";
|
|
36
|
-
import {
|
|
37
|
+
import { runDecides, type HostAdmission } from "./host-admission.js";
|
|
37
38
|
import { PROBE_NOT_ATTEMPTED, PROBE_OK, type ProbeGridPoint, type ProbeOutput } from "./probe-output.js";
|
|
38
39
|
import type { QualifyVerdict } from "./qualify-cli.js";
|
|
39
40
|
import { RATING_SEVERITY, worstRating, type SuitabilityRating } from "./rating.js";
|
|
40
41
|
import type { IntegrityMeasurement, VoicesHeld } from "./measurement/format.js";
|
|
41
|
-
import {
|
|
42
|
+
import { isOutOfMemory } from "./memory-bound.js";
|
|
43
|
+
import type { MemoryFootprint } from "./measurement/format.js";
|
|
42
44
|
|
|
43
45
|
/** Facts of the descriptor, which the probe output repeats none of. */
|
|
44
46
|
export interface JudgeFacts {
|
|
@@ -202,6 +204,13 @@ function decidingOf(reasons: readonly RecordReason[], rating: SuitabilityRating)
|
|
|
202
204
|
return [...reasons].sort((a, b) => RATING_SEVERITY[b.rating] - RATING_SEVERITY[a.rating]).find((r) => r.rating === rating);
|
|
203
205
|
}
|
|
204
206
|
|
|
207
|
+
/** The footprints of the cells that ran to their end, as the verdict reads them. */
|
|
208
|
+
function gridMemoryReadings(output: ProbeOutput): Record<string, MemoryFootprint> {
|
|
209
|
+
const out: Record<string, MemoryFootprint> = {};
|
|
210
|
+
for (const p of output.grid) if (p.outcome === PROBE_OK && p.memory !== undefined) out[costPointKey(p.rate, p.quantum)] = p.memory;
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
|
|
205
214
|
/** The grid point at a host's own cell, whatever its outcome; `undefined` when the grid does not hold that cell. */
|
|
206
215
|
function cellOf(output: ProbeOutput, profile: HostProfile): ProbeGridPoint | undefined {
|
|
207
216
|
return output.grid.find((p) => p.rate === profile.cost.rate && p.quantum === profile.cost.quantum);
|
|
@@ -238,7 +247,7 @@ function costOf(output: ProbeOutput, profile: HostProfile): HostJudgment["cost"]
|
|
|
238
247
|
function failedOutcome(output: ProbeOutput): string | undefined {
|
|
239
248
|
if (output.run.outcome !== PROBE_OK) return output.run.outcome;
|
|
240
249
|
// a cell that outgrew the memory bound is a recorded outcome of that cell, judged per host below, not the run's end
|
|
241
|
-
return output.grid.find((p) => p.outcome !== PROBE_OK && p.outcome !== PROBE_NOT_ATTEMPTED && p.outcome
|
|
250
|
+
return output.grid.find((p) => p.outcome !== PROBE_OK && p.outcome !== PROBE_NOT_ATTEMPTED && !isOutOfMemory(p.outcome))?.outcome;
|
|
242
251
|
}
|
|
243
252
|
|
|
244
253
|
/**
|
|
@@ -282,11 +291,15 @@ export function judgeProbeOutput(output: ProbeOutput, profiles: readonly HostPro
|
|
|
282
291
|
}
|
|
283
292
|
// the plugin did not run within the memory bound at this host's own rate and quantum: not hostable there as measured
|
|
284
293
|
const cell = cellOf(output, profile);
|
|
285
|
-
if (cell
|
|
286
|
-
reasons = [...reasons, { dimension: "memory", rating: "unknown", code:
|
|
294
|
+
if (cell !== undefined && isOutOfMemory(cell.outcome)) {
|
|
295
|
+
reasons = [...reasons, { dimension: "memory", rating: "unknown", code: cell.outcome, params: { rate: cell.rate, quantum: cell.quantum, ...memoryParams(cell) } }];
|
|
296
|
+
} else {
|
|
297
|
+
// what this host's budget makes of the footprint, every cell it runs: part of the verdict, unlike the cost beside it
|
|
298
|
+
reasons = [...reasons, ...hostingMemoryReasons({ perRateQuantum: gridMemoryReadings(output) }, profile).map(asRecordReason)];
|
|
287
299
|
}
|
|
288
300
|
const admission = facts.admissions?.[profile.id];
|
|
289
|
-
|
|
301
|
+
const ran = admission !== undefined && runDecides(profile, output.plugin.format, admission);
|
|
302
|
+
if (ran && admission !== undefined) {
|
|
290
303
|
if (!admission.measured || !admission.admitted) {
|
|
291
304
|
// the host could not be run, or the plugin did not survive it: the run decides, whatever the declaration says
|
|
292
305
|
const code = admission.code ?? `${admission.host}.not-admitted`;
|
|
@@ -312,7 +325,7 @@ export function judgeProbeOutput(output: ProbeOutput, profiles: readonly HostPro
|
|
|
312
325
|
...(deciding === undefined ? {} : { deciding: deciding.code }),
|
|
313
326
|
},
|
|
314
327
|
hosting: rating === "suitable" && profile.isolation === "in-process" ? "in-process" : "isolated",
|
|
315
|
-
arm:
|
|
328
|
+
arm: ran ? "run" : "declaration",
|
|
316
329
|
reasons,
|
|
317
330
|
cost: costOf(output, profile),
|
|
318
331
|
};
|
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
|
|