@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/clap-qualify.ts
CHANGED
|
@@ -36,7 +36,7 @@ import {
|
|
|
36
36
|
import type { QualifyVerdict } from "./qualify-cli.js";
|
|
37
37
|
import { RATING_SEVERITY, worstRating, type SuitabilityRating } from "./rating.js";
|
|
38
38
|
import { halfPopulatedRefusal, locateNativeTools } from "./tool-paths.js";
|
|
39
|
-
import { readIntegrity, type IntegrityMeasurement, type VoicesHeld } from "./measurement/format.js";
|
|
39
|
+
import { readIntegrity, readMemoryFootprint, type IntegrityMeasurement, type MemoryFootprint, type VoicesHeld } from "./measurement/format.js";
|
|
40
40
|
import { isVoiceBasis } from "./measurement/vocabulary.js";
|
|
41
41
|
import { coreFraction } from "./cost-fields.js";
|
|
42
42
|
|
|
@@ -103,6 +103,8 @@ export interface ClapCostReading {
|
|
|
103
103
|
/** The percentile `nsPerSampleP95` is taken at. */
|
|
104
104
|
readonly percentile: number;
|
|
105
105
|
readonly perRate: Readonly<Record<string, ClapCostAtRate>>;
|
|
106
|
+
/** What one instance held at each rate (`memory` on the prober's per-rate object), read apart from the timing. */
|
|
107
|
+
readonly memory?: Readonly<Record<string, MemoryFootprint>>;
|
|
106
108
|
}
|
|
107
109
|
|
|
108
110
|
/** One plugin's line from `clap-qualify`. */
|
|
@@ -285,9 +287,12 @@ function readCost(v: unknown): ClapCostReading {
|
|
|
285
287
|
const perRateIn = v["perRate"];
|
|
286
288
|
if (!isRecord(perRateIn)) return bad("cost has no perRate map");
|
|
287
289
|
const perRate: Record<string, ClapCostAtRate> = {};
|
|
290
|
+
const memory: Record<string, MemoryFootprint> = {};
|
|
288
291
|
for (const [rate, at] of Object.entries(perRateIn)) {
|
|
289
292
|
if (!/^[1-9]\d*$/.test(rate)) return bad(`cost rate ${rate}`);
|
|
290
293
|
perRate[rate] = readCostAtRate(at, `cost at ${rate}`, stimulus === "notes-held");
|
|
294
|
+
const footprint = isRecord(at) ? readMemoryFootprint(at["memory"]) : undefined;
|
|
295
|
+
if (footprint !== undefined) memory[rate] = footprint;
|
|
291
296
|
}
|
|
292
297
|
return {
|
|
293
298
|
stimulus,
|
|
@@ -295,6 +300,7 @@ function readCost(v: unknown): ClapCostReading {
|
|
|
295
300
|
warmupBlocks: count(v, "warmupBlocks", "cost"),
|
|
296
301
|
percentile: figure(v, "percentile", "cost"),
|
|
297
302
|
perRate,
|
|
303
|
+
...(Object.keys(memory).length === 0 ? {} : { memory }),
|
|
298
304
|
};
|
|
299
305
|
}
|
|
300
306
|
|
|
@@ -338,6 +344,7 @@ export function qualifiedPluginOfClapLine(line: ClapQualifyLine, stability?: Plu
|
|
|
338
344
|
clap,
|
|
339
345
|
rtSafety: { ...rtSafety, noteSweep },
|
|
340
346
|
cpuCost: { perRate: clapCostPerRate(line.cost) },
|
|
347
|
+
...(line.cost?.memory === undefined ? {} : { memory: { perRate: line.cost.memory } }),
|
|
341
348
|
...(stability === undefined ? {} : { stability }),
|
|
342
349
|
};
|
|
343
350
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
// Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
|
|
3
|
+
/**
|
|
4
|
+
* The hard limit of a process plugin-qualify spawns directly. Through plugin-hostd's runner a measured child has `memory.max`;
|
|
5
|
+
* a direct spawn has no runner, so it gets `RLIMIT_AS`, which needs no privilege. It is off for a sanitizer build, which
|
|
6
|
+
* reserves far more address space than it uses and would fail at start under any limit worth having.
|
|
7
|
+
*
|
|
8
|
+
* Hitting the limit is an outcome: the interposer ends the child with `MEMORY_BOUND_EXIT_STATUS` on the first refused
|
|
9
|
+
* allocation when `MEMORY_BOUND_EXIT_ENV` is set ({@link addressSpaceLimited} sets it), and {@link directOutcome} names it.
|
|
10
|
+
*/
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { totalmem } from "node:os";
|
|
13
|
+
|
|
14
|
+
import { ADDRESS_SPACE_LIMIT_RAM_FRACTION, MEMORY_BOUND_EXIT_ENV, MEMORY_BOUND_EXIT_STATUS } from "./qualify-declarations.js";
|
|
15
|
+
|
|
16
|
+
/** The outcome of a direct spawn that hit its limit. */
|
|
17
|
+
export const MEMORY_LIMIT_EXCEEDED = "memory-limit-exceeded";
|
|
18
|
+
export const ADDRESS_SPACE_MECHANISM = "rlimit-as";
|
|
19
|
+
|
|
20
|
+
const SANITIZER_ENTRY = ["__asan_init", "__tsan_init", "__msan_init"];
|
|
21
|
+
|
|
22
|
+
/** Whether the program is built with a sanitizer that reserves address space: its own init symbol is in the binary. */
|
|
23
|
+
export function isSanitizerBuild(tool: string, read: (path: string) => Buffer = (p) => readFileSync(p)): boolean {
|
|
24
|
+
let bytes: Buffer;
|
|
25
|
+
try {
|
|
26
|
+
bytes = read(tool);
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
return SANITIZER_ENTRY.some((s) => bytes.includes(s));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface DirectSpawn {
|
|
34
|
+
readonly command: string;
|
|
35
|
+
readonly args: readonly string[];
|
|
36
|
+
readonly env: Readonly<Record<string, string>>;
|
|
37
|
+
/** The limit it runs under, in bytes; absent for a sanitizer build. */
|
|
38
|
+
readonly limitBytes?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The limit a direct spawn takes by default: `ADDRESS_SPACE_LIMIT_RAM_FRACTION` of the machine's RAM. */
|
|
42
|
+
export const defaultAddressSpaceLimit = (ramBytes: number = totalmem()): number => Math.floor(ramBytes * ADDRESS_SPACE_LIMIT_RAM_FRACTION);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* How to spawn `tool` under the limit: the shell sets it and execs the program, since node has no way to set a child's
|
|
46
|
+
* rlimit. The program's own process stays the one spawned, so a signal or a timeout reaches it as before.
|
|
47
|
+
*/
|
|
48
|
+
export function addressSpaceLimited(
|
|
49
|
+
tool: string,
|
|
50
|
+
args: readonly string[],
|
|
51
|
+
limitBytes: number = defaultAddressSpaceLimit(),
|
|
52
|
+
sanitizer: boolean = isSanitizerBuild(tool),
|
|
53
|
+
): DirectSpawn {
|
|
54
|
+
if (sanitizer) return { command: tool, args, env: {} };
|
|
55
|
+
const kb = Math.ceil(limitBytes / 2 ** 10);
|
|
56
|
+
return {
|
|
57
|
+
command: "/bin/sh",
|
|
58
|
+
args: ["-c", `ulimit -v ${String(kb)} && exec "$@"`, "sh", tool, ...args],
|
|
59
|
+
env: { [MEMORY_BOUND_EXIT_ENV]: "1" },
|
|
60
|
+
limitBytes,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** What the end of a direct spawn under a limit comes to: the outcome and the limit when it hit it, else nothing. */
|
|
65
|
+
export function directOutcome(status: number | null, spawn: DirectSpawn): { readonly outcome: string; readonly memoryLimit: { readonly bytes: number; readonly mechanism: string } } | undefined {
|
|
66
|
+
if (spawn.limitBytes === undefined || status !== MEMORY_BOUND_EXIT_STATUS) return undefined;
|
|
67
|
+
return { outcome: MEMORY_LIMIT_EXCEEDED, memoryLimit: { bytes: spawn.limitBytes, mechanism: ADDRESS_SPACE_MECHANISM } };
|
|
68
|
+
}
|
package/src/host-admission.ts
CHANGED
|
@@ -24,11 +24,20 @@ export function admittedHostOf(profile: HostProfile): AdmittedHost | undefined {
|
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* plugin-hostd's LV2 adapter, omx-clap-host: an LV2 plugin is run under it once, as under mod-host, and the record
|
|
27
|
-
* carries the outcome as its own step
|
|
28
|
-
*
|
|
27
|
+
* carries the outcome as its own step (plugin-qualify#223). The run decides the omx-clap-host verdict of an LV2
|
|
28
|
+
* plugin, the isolated tier, as a mod-host run decides mod-host's; a CLAP plugin's stays the measurement.
|
|
29
29
|
*/
|
|
30
30
|
export const ADAPTER_HOST = "omx-clap-host";
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Whether this run decides the profile's verdict for a plugin of this format. A mod-host or jalv run always does, an
|
|
34
|
+
* unmeasured one making the verdict unknown; the adapter's only when it measured, since the omx-clap-host verdict
|
|
35
|
+
* otherwise stands on the measurement.
|
|
36
|
+
*/
|
|
37
|
+
export function runDecides(profile: HostProfile, format: string, admission: HostAdmission): boolean {
|
|
38
|
+
return admittedHostOf(profile) !== undefined || (profile.id === ADAPTER_HOST && format === "lv2" && admission.measured);
|
|
39
|
+
}
|
|
40
|
+
|
|
32
41
|
/** What running the plugin under one host came to. */
|
|
33
42
|
export interface HostAdmission {
|
|
34
43
|
readonly host: AdmittedHost | typeof ADAPTER_HOST;
|
|
@@ -62,12 +71,16 @@ export interface ModHostStep {
|
|
|
62
71
|
/** What `tools/mod-host-admit.mjs` printed. */
|
|
63
72
|
export interface ModHostRun {
|
|
64
73
|
readonly installed: boolean;
|
|
74
|
+
/** False when the host never took a connection (omx-clap-host with no JACK server): no plugin was loaded. */
|
|
75
|
+
readonly started?: boolean;
|
|
65
76
|
readonly steps: readonly ModHostStep[];
|
|
66
77
|
}
|
|
67
78
|
|
|
68
79
|
/** The verdict of a run of a host that speaks mod-host's protocol: the first step that did not answer `resp 0` decides. */
|
|
69
80
|
export function classifyProtocolRun(host: ProtocolHost, run: ModHostRun): HostAdmission {
|
|
70
81
|
if (!run.installed) return notInstalled(host);
|
|
82
|
+
// the host never listened, so the plugin was never loaded: nothing was learned about it
|
|
83
|
+
if (run.started === false) return { host, measured: false, admitted: false, code: `${host}.not-started`, params: {} };
|
|
71
84
|
for (const s of run.steps) {
|
|
72
85
|
if (s.died !== undefined) {
|
|
73
86
|
if (s.died.startsWith("SIG")) return refused(host, `signal-${s.died}`, { step: s.cmd });
|
package/src/host-profiles.ts
CHANGED
|
@@ -164,8 +164,24 @@ export const OMX_CLAP_HOST_PROFILE: HostProfile = {
|
|
|
164
164
|
isolation: "shared-process",
|
|
165
165
|
};
|
|
166
166
|
|
|
167
|
+
/**
|
|
168
|
+
* A Raspberry Pi with 4 GB, and with 8 GB: what runs a plugin there is omx-clap-host under plugin-hostd, so the same
|
|
169
|
+
* extensions, instruments and isolation, on the rates, quanta and memory of the board. Its memory budget is provisional
|
|
170
|
+
* ({@link HOST_BUDGETS}), so a plugin under it is `conditional` until the numbers are measured on a Pi, and the reason says
|
|
171
|
+
* so. Judged at every cell the board runs.
|
|
172
|
+
*/
|
|
173
|
+
export const PI_4GB_PROFILE: HostProfile = { ...OMX_CLAP_HOST_PROFILE, id: "openmixer-pi-4gb", cost: HOST_BUDGETS["openmixer-pi-4gb"] };
|
|
174
|
+
export const PI_8GB_PROFILE: HostProfile = { ...OMX_CLAP_HOST_PROFILE, id: "openmixer-pi-8gb", cost: HOST_BUDGETS["openmixer-pi-8gb"] };
|
|
175
|
+
|
|
167
176
|
/** Every profile the tool ships, by id. */
|
|
168
|
-
export const HOST_PROFILES: readonly HostProfile[] = [
|
|
177
|
+
export const HOST_PROFILES: readonly HostProfile[] = [
|
|
178
|
+
OPENMIXER_CONSOLE_PROFILE,
|
|
179
|
+
JALV_PROFILE,
|
|
180
|
+
MOD_HOST_PROFILE,
|
|
181
|
+
OMX_CLAP_HOST_PROFILE,
|
|
182
|
+
PI_4GB_PROFILE,
|
|
183
|
+
PI_8GB_PROFILE,
|
|
184
|
+
];
|
|
169
185
|
|
|
170
186
|
/** A shipped profile by id, or `undefined`. */
|
|
171
187
|
export function shippedHostProfile(id: string): HostProfile | undefined {
|
|
@@ -32,6 +32,10 @@ export const HOSTING_CODE = Object.freeze({
|
|
|
32
32
|
"latencyParameterDependent": "hosting.latency.parameter-dependent",
|
|
33
33
|
"latencyResolved": "hosting.latency.resolved",
|
|
34
34
|
"latencyUnmeasured": "hosting.latency.unmeasured",
|
|
35
|
+
"memoryAboveCeiling": "hosting.memory.above-ceiling",
|
|
36
|
+
"memoryUnmeasured": "hosting.memory.unmeasured",
|
|
37
|
+
"memoryWithinCeiling": "hosting.memory.within-ceiling",
|
|
38
|
+
"memoryWithinProvisionalCeiling": "hosting.memory.within-provisional-ceiling",
|
|
35
39
|
"outputNonFinite": "hosting.output.non-finite",
|
|
36
40
|
"overrideIsolated": "hosting.override.curator-isolated",
|
|
37
41
|
"patchBusy": "hosting.patch.busy",
|
|
@@ -45,6 +49,9 @@ export const HOSTING_CODE = Object.freeze({
|
|
|
45
49
|
"noRealisation": "hosting.no-realisation",
|
|
46
50
|
"realisedInProcess": "hosting.realised.in-process",
|
|
47
51
|
"unjudged": "hosting.unjudged",
|
|
52
|
+
"resourceHintsOutOfRange": "hosting.resource-hints.out-of-range",
|
|
53
|
+
"resourceHintsUnknownHint": "hosting.resource-hints.unknown-hint",
|
|
54
|
+
"resourceHintsWrongUnit": "hosting.resource-hints.wrong-unit",
|
|
48
55
|
"rtSafetyClean": "hosting.rt-safety.clean-under-sweep",
|
|
49
56
|
"rtSafetyNotReproducible": "hosting.rt-safety.not-reproducible",
|
|
50
57
|
"rtSafetyNotesClean": "hosting.rt-safety.clean-under-notes",
|
|
@@ -81,4 +88,7 @@ export const HOSTING_CODE = Object.freeze({
|
|
|
81
88
|
"topologyWiderThanStrip": "hosting.topology.wider-than-strip",
|
|
82
89
|
"workerNonconforming": "hosting.worker.nonconforming",
|
|
83
90
|
"idleHoldRefused": "hosting.idle.hold-refused",
|
|
91
|
+
"windowNoDisplay": "hosting.window.no-display",
|
|
92
|
+
"windowUnsupported": "hosting.window.unsupported",
|
|
93
|
+
"windowRefused": "hosting.window.refused",
|
|
84
94
|
} as const);
|
|
@@ -30,6 +30,7 @@ import type {
|
|
|
30
30
|
SoakReading,
|
|
31
31
|
ThreadReading,
|
|
32
32
|
} from "./measurement/format.js";
|
|
33
|
+
import { memoryWithinBudget, type MemoryReadings } from "./memory-budget.js";
|
|
33
34
|
|
|
34
35
|
/**
|
|
35
36
|
* What a reason may interpolate: wire-serialisable scalars only. Structurally the same record as
|
|
@@ -71,6 +72,8 @@ export interface QualifiedPlugin {
|
|
|
71
72
|
readonly lv2Features?: PluginLv2Features;
|
|
72
73
|
/** The plugin's binary format, the scan's declared fact (CLAP spec §6); absent reads `lv2`. */
|
|
73
74
|
readonly format?: "lv2" | "clap";
|
|
75
|
+
/** What one instance holds: per rate, or per rate AND quantum as {@link cpuCost} has it. A missing cell is unmeasured. */
|
|
76
|
+
readonly memory?: MemoryReadings;
|
|
74
77
|
/** What the CLAP prober (`native/clap-qualify`) measured, for a `clap` plugin (CLAP spec §3). */
|
|
75
78
|
readonly clap?: PluginClapMeasurement;
|
|
76
79
|
}
|
|
@@ -78,14 +81,15 @@ export interface QualifiedPlugin {
|
|
|
78
81
|
/** Where a plugin's audio is actually processed. */
|
|
79
82
|
export type HostingPath = "in-process" | "isolated";
|
|
80
83
|
|
|
81
|
-
/** The dimensions that decide a host
|
|
84
|
+
/** The dimensions that decide a host, folded worst-wins. */
|
|
82
85
|
export type HostingDimension =
|
|
83
86
|
| "stability"
|
|
84
87
|
| "rtSafety"
|
|
85
88
|
| "features"
|
|
86
89
|
| "cost"
|
|
87
90
|
| "latency"
|
|
88
|
-
| "topology"
|
|
91
|
+
| "topology"
|
|
92
|
+
| "memory";
|
|
89
93
|
|
|
90
94
|
// The in-process feature list and the CLAP host extensions are the `openmixer-console` host
|
|
91
95
|
// profile's; they live with the profiles (`host-profiles.ts`) and are re-exported here, where the
|
|
@@ -446,13 +450,15 @@ export function deadAgainstBest(levelDbfs: number, bestDbfs: number, policy: Hos
|
|
|
446
450
|
}
|
|
447
451
|
|
|
448
452
|
/** The classes of soak failure, as the strings `failures` is built from. */
|
|
449
|
-
export type SoakFailureKind = "died" | "non-finite" | "dead" | "mostly-silent" | "startup-blast" | "timed-out";
|
|
453
|
+
export type SoakFailureKind = "died" | "non-finite" | "dead" | "mostly-silent" | "startup-blast" | "timed-out" | "leaks";
|
|
450
454
|
|
|
451
455
|
export interface SoakFailure {
|
|
452
456
|
readonly rate: number;
|
|
453
457
|
readonly kind: SoakFailureKind;
|
|
454
458
|
/** Extra words for the string form, e.g. `20dBFS`. */
|
|
455
459
|
readonly detail?: string;
|
|
460
|
+
/** For `leaks`: what the growth was and the ceiling it passed, which the reason carries. */
|
|
461
|
+
readonly params?: CodedRefusalParams;
|
|
456
462
|
}
|
|
457
463
|
|
|
458
464
|
/** A soak's reading: what voided it, if anything, and what it proved when nothing did. */
|
|
@@ -465,7 +471,7 @@ export interface SoakFindings {
|
|
|
465
471
|
* cut, a fact about the harness: it voids the soak but is never a failure of the plugin. */
|
|
466
472
|
readonly failed: readonly SoakFailure[];
|
|
467
473
|
readonly ratesTested: number;
|
|
468
|
-
/** The largest RSS growth across rates, for the record;
|
|
474
|
+
/** The largest RSS growth across rates, warm-up included, for the record; `leaks` is what is judged. */
|
|
469
475
|
readonly rssGrowthKb: number;
|
|
470
476
|
}
|
|
471
477
|
|
|
@@ -473,6 +479,17 @@ export interface SoakFindings {
|
|
|
473
479
|
* settled above it cannot be the reference, or every sane rate reads dead against it. */
|
|
474
480
|
const BLAST_DBFS = 6;
|
|
475
481
|
|
|
482
|
+
/**
|
|
483
|
+
* The growth a rate's soak is still showing, in KB per minute of audio: from halfway past warm-up to its highest reading,
|
|
484
|
+
* so that a library that finishes loading a little late is not a leak and a leak of any size is. `undefined` when the soak
|
|
485
|
+
* did not reach those readings: not read as flat.
|
|
486
|
+
*/
|
|
487
|
+
export function soakGrowthKbPerMin(v: SoakAtRate): number | undefined {
|
|
488
|
+
const { rssMidKb, midSeconds, rssMaxKb, seconds } = v;
|
|
489
|
+
if (rssMidKb === undefined || midSeconds === undefined || rssMaxKb === undefined || seconds === undefined || seconds <= midSeconds) return undefined;
|
|
490
|
+
return (Math.max(rssMaxKb - rssMidKb, 0) * 60) / (seconds - midSeconds);
|
|
491
|
+
}
|
|
492
|
+
|
|
476
493
|
/**
|
|
477
494
|
* Read a soak, rate by rate. A rate voids it if the plugin did not instantiate or died there,
|
|
478
495
|
* emitted a non-finite window, settled dead against its own best rate ({@link deadAgainstBest};
|
|
@@ -516,7 +533,23 @@ export function soakFindings(soak: SoakReading | undefined, policy: HostingPolic
|
|
|
516
533
|
continue;
|
|
517
534
|
}
|
|
518
535
|
const first = v.firstRmsDbfs ?? -99;
|
|
519
|
-
if (first > BLAST_DBFS)
|
|
536
|
+
if (first > BLAST_DBFS) {
|
|
537
|
+
failed.push({ rate, kind: "startup-blast", detail: `${first.toFixed(0)}dBFS` });
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
const growth = soakGrowthKbPerMin(v);
|
|
541
|
+
if (growth !== undefined && growth > policy.memoryGrowthKbPerMinCeiling) {
|
|
542
|
+
failed.push({
|
|
543
|
+
rate,
|
|
544
|
+
kind: "leaks",
|
|
545
|
+
detail: `${growth.toFixed(0)}KB/min`,
|
|
546
|
+
params: {
|
|
547
|
+
growthKbPerMin: Math.round(growth),
|
|
548
|
+
ceilingKbPerMin: policy.memoryGrowthKbPerMinCeiling,
|
|
549
|
+
...(policy.memoryGrowthCeilingProvisional ? { provisional: "yes" as const } : {}),
|
|
550
|
+
},
|
|
551
|
+
});
|
|
552
|
+
}
|
|
520
553
|
}
|
|
521
554
|
const failures = failed.map((f) => `${f.kind}@${f.rate}${f.detail === undefined ? "" : `(${f.detail})`}`);
|
|
522
555
|
return {
|
|
@@ -592,6 +625,7 @@ export function stabilityReasons(
|
|
|
592
625
|
reason("stability", "objective", "unsuitable", HOSTING_CODE.stabilitySoakFailedAtRate, {
|
|
593
626
|
failure: f.kind,
|
|
594
627
|
rate: f.rate,
|
|
628
|
+
...f.params,
|
|
595
629
|
}),
|
|
596
630
|
);
|
|
597
631
|
}
|
|
@@ -650,9 +684,11 @@ export function rtSafetyReasons(
|
|
|
650
684
|
if (m.variable === true) {
|
|
651
685
|
return [reason("rtSafety", "objective", "unknown", HOSTING_CODE.rtSafetyNotReproducible)];
|
|
652
686
|
}
|
|
687
|
+
// RT-unsafe is a hosting mode, never a refusal (#196): the plugin loads and runs in a worker of its own, never
|
|
688
|
+
// grouped with RT-safe plugins; conditional is isolated in the pin. Crashes, hangs and load failures stay unsuitable.
|
|
653
689
|
if (rtSafetyViolating(m)) {
|
|
654
690
|
return [
|
|
655
|
-
reason("rtSafety", "objective", "
|
|
691
|
+
reason("rtSafety", "objective", "conditional", HOSTING_CODE.rtSafetyViolations, {
|
|
656
692
|
allocations: m.allocationsInRun ?? 0,
|
|
657
693
|
syscalls: m.syscallsInRun ?? 0,
|
|
658
694
|
locks: m.locksInRun ?? 0,
|
|
@@ -777,6 +813,60 @@ export function hostingCostReasons(
|
|
|
777
813
|
return out;
|
|
778
814
|
}
|
|
779
815
|
|
|
816
|
+
/**
|
|
817
|
+
* What a host's memory budget makes of a plugin. A host with no budget declared reads nothing here. Every cell the host
|
|
818
|
+
* runs is read; the worst stands, so a plugin is never read as fitting on the cell that happened to be measured. A missing
|
|
819
|
+
* figure is `unknown`, never a fit. Under a provisional budget a plugin below the ceiling is `conditional` with a code of its
|
|
820
|
+
* own, since the number it was held to was not measured on that kind of host; one above it is `unsuitable` and says the
|
|
821
|
+
* ceiling is provisional.
|
|
822
|
+
*/
|
|
823
|
+
export function hostingMemoryReasons(
|
|
824
|
+
memory: MemoryReadings | undefined,
|
|
825
|
+
profile: HostProfile,
|
|
826
|
+
/** The live rate and quantum, for a host whose budget names no cells of its own; absent, the cell its cost is read at. */
|
|
827
|
+
live?: { readonly rate: number; readonly quantum?: number },
|
|
828
|
+
): readonly HostingReason[] {
|
|
829
|
+
const budget = profile.cost.memory;
|
|
830
|
+
if (budget === undefined) return [];
|
|
831
|
+
const provisional: CodedRefusalParams = budget.provisional === true ? { provisional: "yes" } : {};
|
|
832
|
+
const cells = budget.cells ?? [{ rate: live?.rate ?? profile.cost.rate, quantum: live?.quantum ?? profile.cost.quantum }];
|
|
833
|
+
const reads = cells.map((c) => ({ ...c, read: memoryWithinBudget(memoryAt(memory, c.rate, c.quantum), budget) }));
|
|
834
|
+
const above = reads.find((r) => r.read.outcome === "above");
|
|
835
|
+
if (above !== undefined && above.read.outcome === "above") {
|
|
836
|
+
return [
|
|
837
|
+
reason("memory", "objective", "unsuitable", HOSTING_CODE.memoryAboveCeiling, {
|
|
838
|
+
rate: above.rate,
|
|
839
|
+
quantum: above.quantum,
|
|
840
|
+
figure: above.read.figure,
|
|
841
|
+
kb: above.read.kb,
|
|
842
|
+
ceilingKb: above.read.ceilingKb,
|
|
843
|
+
...provisional,
|
|
844
|
+
}),
|
|
845
|
+
];
|
|
846
|
+
}
|
|
847
|
+
const unmeasured = reads.find((r) => r.read.outcome === "unmeasured");
|
|
848
|
+
if (unmeasured !== undefined && unmeasured.read.outcome === "unmeasured") {
|
|
849
|
+
return [
|
|
850
|
+
reason("memory", "objective", "unknown", HOSTING_CODE.memoryUnmeasured, {
|
|
851
|
+
rate: unmeasured.rate,
|
|
852
|
+
quantum: unmeasured.quantum,
|
|
853
|
+
...(unmeasured.read.unmeasuredReason === undefined ? {} : { unmeasuredReason: unmeasured.read.unmeasuredReason }),
|
|
854
|
+
}),
|
|
855
|
+
];
|
|
856
|
+
}
|
|
857
|
+
const first = reads[0]!.read;
|
|
858
|
+
const params: CodedRefusalParams = first.outcome === "within" ? { perInstanceKb: first.perInstanceKb, firstInstanceKb: first.firstInstanceKb, ceilingKb: budget.perInstanceKb, consoleCeilingKb: budget.perConsoleKb } : {};
|
|
859
|
+
return budget.provisional === true
|
|
860
|
+
? [reason("memory", "objective", "conditional", HOSTING_CODE.memoryWithinProvisionalCeiling, { ...params, ...provisional })]
|
|
861
|
+
: [reason("memory", "objective", "suitable", HOSTING_CODE.memoryWithinCeiling, params)];
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/** The footprint at a rate and quantum: a grid's cell when the plugin has a grid, else its rate's. */
|
|
865
|
+
function memoryAt(memory: MemoryReadings | undefined, rate: number, quantum: number) {
|
|
866
|
+
if (memory?.perRateQuantum !== undefined) return memory.perRateQuantum[costPointKey(rate, quantum)];
|
|
867
|
+
return memory?.perRate?.[String(rate)];
|
|
868
|
+
}
|
|
869
|
+
|
|
780
870
|
export function hostingLatencyReasons(
|
|
781
871
|
descriptor: Pick<QualifiedPlugin, "latency">,
|
|
782
872
|
handling: HostProfile["latency"] = "compensated",
|
|
@@ -924,7 +1014,8 @@ export function clapFeatureReasons(
|
|
|
924
1014
|
/**
|
|
925
1015
|
* The CLAP additions to `rtSafety` (CLAP spec §3): the interposer's counts are read by
|
|
926
1016
|
* `rtSafetyReasons` exactly as for an LV2; these are the two violations only a CLAP can commit
|
|
927
|
-
* — a strict thread-check violation and a `CLAP_PROCESS_ERROR`
|
|
1017
|
+
* — a strict thread-check violation (`conditional`: the plugin runs isolated, #196) and a `CLAP_PROCESS_ERROR`
|
|
1018
|
+
* (`unsuitable`). A sweep
|
|
928
1019
|
* that made no `process()` call measured nothing.
|
|
929
1020
|
*/
|
|
930
1021
|
export function clapRtReasons(clap: PluginClapMeasurement | undefined): readonly HostingReason[] {
|
|
@@ -933,7 +1024,7 @@ export function clapRtReasons(clap: PluginClapMeasurement | undefined): readonly
|
|
|
933
1024
|
}
|
|
934
1025
|
const out: HostingReason[] = [];
|
|
935
1026
|
if (clap.threadViolations > 0) {
|
|
936
|
-
out.push(reason("rtSafety", "objective", "
|
|
1027
|
+
out.push(reason("rtSafety", "objective", "conditional", HOSTING_CODE.clapThreadViolation, { violations: clap.threadViolations }));
|
|
937
1028
|
}
|
|
938
1029
|
if (clap.processErrors > 0) {
|
|
939
1030
|
out.push(reason("rtSafety", "objective", "unsuitable", HOSTING_CODE.clapProcessError, { errors: clap.processErrors }));
|
|
@@ -945,8 +1036,9 @@ export function clapRtReasons(clap: PluginClapMeasurement | undefined): readonly
|
|
|
945
1036
|
* The rtSafety reasons of the note sweep, for a plugin with `noteInputs` note inputs: none for a
|
|
946
1037
|
* plugin that takes no notes; `unknown` when it takes notes and was not played under the
|
|
947
1038
|
* interposer (an instrument is cheap and clean at rest, so the parameter sweep alone says nothing
|
|
948
|
-
* about a voice); `
|
|
949
|
-
*
|
|
1039
|
+
* about a voice); `conditional` (isolated in the pin: its own worker, never refused) on any
|
|
1040
|
+
* allocation, lock or syscall while it was played; `unsuitable` on a process error only (a crash);
|
|
1041
|
+
* `suitable` on a clean count.
|
|
950
1042
|
*/
|
|
951
1043
|
export function noteSweepReasons(sweep: PluginNoteSweepMeasurement | undefined, noteInputs: number): readonly HostingReason[] {
|
|
952
1044
|
if (noteInputs === 0) return [];
|
|
@@ -957,7 +1049,7 @@ export function noteSweepReasons(sweep: PluginNoteSweepMeasurement | undefined,
|
|
|
957
1049
|
const out: HostingReason[] = [];
|
|
958
1050
|
if (sweep.allocationsInRun + sweep.locksInRun + sweep.syscallsInRun > 0) {
|
|
959
1051
|
out.push(
|
|
960
|
-
reason("rtSafety", "objective", "
|
|
1052
|
+
reason("rtSafety", "objective", "conditional", HOSTING_CODE.rtSafetyNotesViolations, {
|
|
961
1053
|
allocations: sweep.allocationsInRun,
|
|
962
1054
|
syscalls: sweep.syscallsInRun,
|
|
963
1055
|
locks: sweep.locksInRun,
|
|
@@ -1002,6 +1094,7 @@ export function classifyPluginHosting(
|
|
|
1002
1094
|
...(profile.instruments ? noteSweepReasons(descriptor.rtSafety?.noteSweep, descriptor.clap?.noteInputs ?? (descriptor.hasMidiIn ? 1 : 0)) : []),
|
|
1003
1095
|
...clapFeatureReasons(descriptor.clap, profile, descriptor.hasMidiIn),
|
|
1004
1096
|
...hostingCostReasons(descriptor, options, policy),
|
|
1097
|
+
...hostingMemoryReasons(descriptor.memory, profile, options),
|
|
1005
1098
|
...clapLatencyReasons(descriptor.clap),
|
|
1006
1099
|
...clapTopologyReasons(descriptor.clap, profile.instruments),
|
|
1007
1100
|
]
|
|
@@ -1011,6 +1104,7 @@ export function classifyPluginHosting(
|
|
|
1011
1104
|
...(profile.instruments ? noteSweepReasons(descriptor.rtSafety?.noteSweep, descriptor.hasMidiIn ? 1 : 0) : []),
|
|
1012
1105
|
...featureReasons(descriptor.lv2Features, descriptor, profile.lv2Features, options.quantum !== undefined, profile.instruments),
|
|
1013
1106
|
...hostingCostReasons(descriptor, options, policy),
|
|
1107
|
+
...hostingMemoryReasons(descriptor.memory, profile, options),
|
|
1014
1108
|
...hostingLatencyReasons(descriptor, profile.latency),
|
|
1015
1109
|
...hostingTopologyReasons(descriptor, profile.instruments),
|
|
1016
1110
|
];
|
package/src/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ export * from "./tool-paths.js";
|
|
|
10
10
|
export * from "./rating.js";
|
|
11
11
|
export * from "./host-profiles.js";
|
|
12
12
|
export * from "./hosting-suitability.js";
|
|
13
|
+
export * from "./memory-budget.js";
|
|
13
14
|
export * from "./hosting-codes.js";
|
|
14
15
|
export * from "./loader.js";
|
|
15
16
|
export * from "./lv2-loader.js";
|
|
@@ -78,8 +78,10 @@ export const MEASUREMENT_FORMAT_MAJOR = 1;
|
|
|
78
78
|
* {@link RtSafetyMeasurement}, {@link FeaturesMeasurement} — which a 1.0 reader skips.
|
|
79
79
|
* `1.2` added the verdicts per host profile; `1.3` the {@link IntegrityMeasurement} block; `1.4` the notes
|
|
80
80
|
* an instrument's cost was measured with ({@link CostAtRate.voices}); `1.5` the note sweep an instrument's
|
|
81
|
-
* `run()` was counted under ({@link RtSafetyMeasurement.noteSweep}).
|
|
82
|
-
|
|
81
|
+
* `run()` was counted under ({@link RtSafetyMeasurement.noteSweep}); `1.6` an instance's memory footprint
|
|
82
|
+
* beside the cost reading ({@link CostAtRate.memory}), the soak's readings past warm-up
|
|
83
|
+
* ({@link SoakAtRate.rssAfterWarmupKb}), and a host profile's memory budget ({@link HostBudget.memory}). */
|
|
84
|
+
export const MEASUREMENT_FORMAT_MINOR = 6;
|
|
83
85
|
|
|
84
86
|
/** The version string this build writes, e.g. `"1.0"`. */
|
|
85
87
|
export const MEASUREMENT_FORMAT_VERSION = `${MEASUREMENT_FORMAT_MAJOR}.${MEASUREMENT_FORMAT_MINOR}`;
|
|
@@ -285,6 +287,28 @@ export interface VoicesHeld {
|
|
|
285
287
|
readonly capacity?: number;
|
|
286
288
|
}
|
|
287
289
|
|
|
290
|
+
/**
|
|
291
|
+
* 1.6: what one instance holds at this rate: how far the resident set of a process of its own grew from before the
|
|
292
|
+
* plugin loaded, in KB, at each step of its life. Raw readings, never a conclusion: `memory-budget.ts` reads them against
|
|
293
|
+
* a budget. `secondInstanceKb` is what a second instance added once it had done the same, so what one instance costs is
|
|
294
|
+
* told from what every instance shares (a sample library, a wavetable, an IR). `notesHeldKb` and its `basis` are an
|
|
295
|
+
* instrument's, at the notes the cost arm holds.
|
|
296
|
+
*
|
|
297
|
+
* A figure not taken is absent, never 0; `unmeasuredReason` (an {@link ../vocabulary.UNMEASURED_TERMS} code) says why
|
|
298
|
+
* one that should be there is not, and may stand beside the figures that were taken.
|
|
299
|
+
*/
|
|
300
|
+
export interface MemoryFootprint {
|
|
301
|
+
readonly afterInstantiateKb?: number;
|
|
302
|
+
readonly afterActivateKb?: number;
|
|
303
|
+
/** Activated, after processing: what a long-running host carries. */
|
|
304
|
+
readonly atRestKb?: number;
|
|
305
|
+
readonly notesHeldKb?: number;
|
|
306
|
+
readonly secondInstanceKb?: number;
|
|
307
|
+
readonly basis?: VoiceBasis;
|
|
308
|
+
readonly unmeasuredReason?: string;
|
|
309
|
+
readonly unmeasuredDetail?: string;
|
|
310
|
+
}
|
|
311
|
+
|
|
288
312
|
/** One rate's cost reading. Per SAMPLE, so it is independent of the block size. */
|
|
289
313
|
export interface CostAtRate {
|
|
290
314
|
readonly nsPerSampleMedian: number;
|
|
@@ -303,6 +327,8 @@ export interface CostAtRate {
|
|
|
303
327
|
readonly silentOutput?: boolean;
|
|
304
328
|
/** 1.4: for an instrument, the notes held while it was timed. Absent for an effect. */
|
|
305
329
|
readonly voices?: VoicesHeld;
|
|
330
|
+
/** 1.6: this instance's memory footprint at the same rate. */
|
|
331
|
+
readonly memory?: MemoryFootprint;
|
|
306
332
|
}
|
|
307
333
|
|
|
308
334
|
/** Everything one run learned about one plugin's CPU cost. */
|
|
@@ -368,6 +394,12 @@ export interface SoakAtRate {
|
|
|
368
394
|
readonly rssStartKb?: number;
|
|
369
395
|
readonly rssMaxKb?: number;
|
|
370
396
|
readonly rssGrowthKb?: number;
|
|
397
|
+
/** 1.6: the resident set past the settling `firstRmsDbfs` skips, and the audio-seconds to reach it. */
|
|
398
|
+
readonly rssAfterWarmupKb?: number;
|
|
399
|
+
readonly warmupSeconds?: number;
|
|
400
|
+
/** 1.6: the resident set halfway from there to the end, so that growth which stopped is told from growth still climbing. */
|
|
401
|
+
readonly rssMidKb?: number;
|
|
402
|
+
readonly midSeconds?: number;
|
|
371
403
|
}
|
|
372
404
|
|
|
373
405
|
/** A continuous soak, per rate, with whether the controls were swept while it ran. */
|
|
@@ -501,6 +533,22 @@ export interface HostBudget {
|
|
|
501
533
|
readonly coreFractionCeiling: number;
|
|
502
534
|
readonly rate: number;
|
|
503
535
|
readonly quantum: number;
|
|
536
|
+
/** 1.6: what this host gives its plugins' memory; absent is a host with no budget declared, never a budget of 0. */
|
|
537
|
+
readonly memory?: HostMemoryBudget;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* 1.6: a host's memory budget, and where it is read. `perInstanceKb` bounds what one more instance costs;
|
|
542
|
+
* `perConsoleKb` bounds all the plugins of the host, so the most one plugin may take with what its instances share.
|
|
543
|
+
* `provisional` says the numbers were not measured on that kind of host: a verdict that rests on them says so and does
|
|
544
|
+
* not say the plugin fits. `cells` are the rates and quanta the host runs, each judged, where it runs more than the one
|
|
545
|
+
* its cost is read at.
|
|
546
|
+
*/
|
|
547
|
+
export interface HostMemoryBudget {
|
|
548
|
+
readonly perInstanceKb: number;
|
|
549
|
+
readonly perConsoleKb: number;
|
|
550
|
+
readonly provisional?: boolean;
|
|
551
|
+
readonly cells?: readonly { readonly rate: number; readonly quantum: number }[];
|
|
504
552
|
}
|
|
505
553
|
|
|
506
554
|
/**
|
|
@@ -554,6 +602,24 @@ const isPositive = (v: unknown): v is number => typeof v === "number" && Number.
|
|
|
554
602
|
* typed; a missing or mistyped one is refused by name, and a host the reader was not told about
|
|
555
603
|
* is never filled in from a default.
|
|
556
604
|
*/
|
|
605
|
+
/** A budget, `undefined` for none declared, or the words of what is wrong with it. */
|
|
606
|
+
function readHostMemoryBudget(value: unknown): HostMemoryBudget | undefined | string {
|
|
607
|
+
if (value === undefined) return undefined;
|
|
608
|
+
if (!isRecord(value)) return "cost.memory: an object, or absent";
|
|
609
|
+
if (!isPositive(value.perInstanceKb) || !isPositive(value.perConsoleKb)) return "cost.memory: perInstanceKb and perConsoleKb are positive numbers";
|
|
610
|
+
if (value.provisional !== undefined && typeof value.provisional !== "boolean") return "cost.memory.provisional: a boolean, or absent";
|
|
611
|
+
const cells = value.cells;
|
|
612
|
+
if (cells !== undefined && (!Array.isArray(cells) || cells.length === 0 || !cells.every((c) => isRecord(c) && Number.isInteger(c.rate) && Number.isInteger(c.quantum) && isPositive(c.rate) && isPositive(c.quantum)))) {
|
|
613
|
+
return "cost.memory.cells: rates and quanta as positive integers, or absent";
|
|
614
|
+
}
|
|
615
|
+
return {
|
|
616
|
+
perInstanceKb: value.perInstanceKb,
|
|
617
|
+
perConsoleKb: value.perConsoleKb,
|
|
618
|
+
...optional("provisional", value.provisional as boolean | undefined),
|
|
619
|
+
...optional("cells", (cells as readonly Record<string, number>[] | undefined)?.map((c) => ({ rate: c.rate!, quantum: c.quantum! }))),
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
|
|
557
623
|
export function readHostProfile(value: unknown): HostProfileRead {
|
|
558
624
|
if (!isRecord(value)) return { ok: false, problem: "a host profile is a JSON object" };
|
|
559
625
|
const { id, lv2Features, clapExtensions, instruments, cost, latency, isolation } = value;
|
|
@@ -569,6 +635,8 @@ export function readHostProfile(value: unknown): HostProfileRead {
|
|
|
569
635
|
if (!Number.isInteger(cost.rate) || !Number.isInteger(cost.quantum)) {
|
|
570
636
|
return { ok: false, problem: "cost: rate and quantum are integers" };
|
|
571
637
|
}
|
|
638
|
+
const memory = readHostMemoryBudget(cost.memory);
|
|
639
|
+
if (typeof memory === "string") return { ok: false, problem: memory };
|
|
572
640
|
if (!(HOST_LATENCY_HANDLING as readonly unknown[]).includes(latency)) {
|
|
573
641
|
return { ok: false, problem: `latency: one of ${HOST_LATENCY_HANDLING.join(", ")}` };
|
|
574
642
|
}
|
|
@@ -582,7 +650,7 @@ export function readHostProfile(value: unknown): HostProfileRead {
|
|
|
582
650
|
lv2Features,
|
|
583
651
|
clapExtensions,
|
|
584
652
|
instruments,
|
|
585
|
-
cost: { coreFractionCeiling: cost.coreFractionCeiling, rate: cost.rate, quantum: cost.quantum },
|
|
653
|
+
cost: { coreFractionCeiling: cost.coreFractionCeiling, rate: cost.rate, quantum: cost.quantum, ...optional("memory", memory) },
|
|
586
654
|
latency: latency as HostLatencyHandling,
|
|
587
655
|
isolation: isolation as HostIsolation,
|
|
588
656
|
},
|
|
@@ -851,6 +919,27 @@ function readLatency(value: unknown): LatencyMeasurement | undefined {
|
|
|
851
919
|
};
|
|
852
920
|
}
|
|
853
921
|
|
|
922
|
+
/** A KB figure: finite and never negative. */
|
|
923
|
+
function kilobytes(value: unknown): number | undefined {
|
|
924
|
+
const n = finiteNumber(value);
|
|
925
|
+
return n !== undefined && n >= 0 ? n : undefined;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/** A memory footprint, or `undefined` unless it carries at least one figure or a reason. */
|
|
929
|
+
export function readMemoryFootprint(value: unknown): MemoryFootprint | undefined {
|
|
930
|
+
if (!isRecord(value)) return undefined;
|
|
931
|
+
const footprint: MemoryFootprint = {
|
|
932
|
+
...optional("afterInstantiateKb", kilobytes(value.afterInstantiateKb)),
|
|
933
|
+
...optional("afterActivateKb", kilobytes(value.afterActivateKb)),
|
|
934
|
+
...optional("atRestKb", kilobytes(value.atRestKb)),
|
|
935
|
+
...optional("notesHeldKb", kilobytes(value.notesHeldKb)),
|
|
936
|
+
...optional("secondInstanceKb", kilobytes(value.secondInstanceKb)),
|
|
937
|
+
...optional("basis", isVoiceBasis(value.basis) ? value.basis : undefined),
|
|
938
|
+
...reasonOf(value),
|
|
939
|
+
};
|
|
940
|
+
return Object.keys(footprint).length > 0 ? footprint : undefined;
|
|
941
|
+
}
|
|
942
|
+
|
|
854
943
|
function readCostAtRate(value: unknown): CostAtRate | undefined {
|
|
855
944
|
if (!isRecord(value)) return undefined;
|
|
856
945
|
const nsPerSampleMedian = finiteNumber(value.nsPerSampleMedian);
|
|
@@ -875,6 +964,7 @@ function readCostAtRate(value: unknown): CostAtRate | undefined {
|
|
|
875
964
|
...optional("warmupNsPerSampleMedian", finiteNumber(value.warmupNsPerSampleMedian)),
|
|
876
965
|
...optional("silentOutput", booleanValue(value.silentOutput)),
|
|
877
966
|
...optional("voices", readVoicesHeld(value.voices)),
|
|
967
|
+
...optional("memory", readMemoryFootprint(value.memory)),
|
|
878
968
|
};
|
|
879
969
|
}
|
|
880
970
|
|
|
@@ -957,6 +1047,10 @@ export function readSoakAtRate(value: unknown): SoakAtRate | undefined {
|
|
|
957
1047
|
...optional("rssStartKb", finiteNumber(value.rssStartKb)),
|
|
958
1048
|
...optional("rssMaxKb", finiteNumber(value.rssMaxKb)),
|
|
959
1049
|
...optional("rssGrowthKb", finiteNumber(value.rssGrowthKb)),
|
|
1050
|
+
...optional("rssAfterWarmupKb", kilobytes(value.rssAfterWarmupKb)),
|
|
1051
|
+
...optional("warmupSeconds", finiteNumber(value.warmupSeconds)),
|
|
1052
|
+
...optional("rssMidKb", kilobytes(value.rssMidKb)),
|
|
1053
|
+
...optional("midSeconds", finiteNumber(value.midSeconds)),
|
|
960
1054
|
};
|
|
961
1055
|
}
|
|
962
1056
|
|
package/src/measurement/plan.ts
CHANGED
|
@@ -38,7 +38,7 @@ export interface InstallationRates {
|
|
|
38
38
|
readonly liveRate?: number;
|
|
39
39
|
/** Rates the active device actually offers. Empty/absent = the console has not probed. */
|
|
40
40
|
readonly supportedRates?: readonly number[];
|
|
41
|
-
/** Rates the
|
|
41
|
+
/** Rates the caller asked for. Absent = "whatever this installation can use". */
|
|
42
42
|
readonly requestedRates?: readonly number[];
|
|
43
43
|
}
|
|
44
44
|
|
|
@@ -47,7 +47,7 @@ export interface RatePlan {
|
|
|
47
47
|
/** Rates to measure, ascending. Empty when nothing could be resolved. */
|
|
48
48
|
readonly rates: readonly number[];
|
|
49
49
|
/**
|
|
50
|
-
* Rates the
|
|
50
|
+
* Rates the caller asked for that the installation does not offer. Reported, not
|
|
51
51
|
* measured — a figure at a rate the rig cannot run is a figure nobody will ever use, and
|
|
52
52
|
* quietly measuring it would make the run longer for nothing.
|
|
53
53
|
*/
|
|
@@ -63,7 +63,7 @@ export interface RatePlan {
|
|
|
63
63
|
* Resolve the rates a local run should measure at.
|
|
64
64
|
*
|
|
65
65
|
* The live rate is always included when known — a console must have figures for the rate it
|
|
66
|
-
* is running at, whatever else the
|
|
66
|
+
* is running at, whatever else the caller asked for. Beyond that: the requested rates
|
|
67
67
|
* intersected with what the device offers, or, absent a request, the whole supported list.
|
|
68
68
|
*/
|
|
69
69
|
export function ratesForInstallation(input: InstallationRates): RatePlan {
|