@openmixer/plugin-qualify 0.16.0 → 0.17.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 +154 -7
- package/bin/plugin-qualify-records-package.mjs +31 -0
- package/bin/plugin-qualify-sync.mjs +6 -1
- package/bin/plugin-qualify.mjs +12 -1
- package/dist/distributed-records.d.ts +88 -0
- package/dist/distributed-records.js +210 -0
- package/dist/distributed-records.js.map +1 -0
- package/dist/host-profiles.d.ts +5 -0
- package/dist/host-profiles.js +13 -0
- package/dist/host-profiles.js.map +1 -1
- package/dist/hosting-suitability.js +9 -1
- package/dist/hosting-suitability.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/measurement-grid.d.ts +49 -0
- package/dist/measurement-grid.js +76 -0
- package/dist/measurement-grid.js.map +1 -0
- package/dist/memory-bound.d.ts +23 -0
- package/dist/memory-bound.js +46 -0
- package/dist/memory-bound.js.map +1 -0
- package/dist/probe-judge.js +22 -1
- package/dist/probe-judge.js.map +1 -1
- package/dist/probe-merge.d.ts +30 -0
- package/dist/probe-merge.js +80 -0
- package/dist/probe-merge.js.map +1 -0
- package/dist/probe-output.d.ts +8 -0
- package/dist/probe-output.js +7 -0
- package/dist/probe-output.js.map +1 -1
- package/dist/probe-runner.d.ts +26 -2
- package/dist/probe-runner.js +38 -10
- package/dist/probe-runner.js.map +1 -1
- package/dist/qualification-record.d.ts +19 -1
- package/dist/qualification-record.js +30 -1
- package/dist/qualification-record.js.map +1 -1
- package/dist/qualify-cli.d.ts +9 -0
- package/dist/qualify-cli.js +30 -1
- package/dist/qualify-cli.js.map +1 -1
- package/dist/qualify-declarations.d.ts +15 -0
- package/dist/qualify-declarations.js +15 -0
- package/dist/qualify-declarations.js.map +1 -1
- package/dist/qualify-options.js +11 -1
- package/dist/qualify-options.js.map +1 -1
- package/dist/qualify-sync.d.ts +23 -3
- package/dist/qualify-sync.js +172 -16
- package/dist/qualify-sync.js.map +1 -1
- package/dist/records-package.d.ts +63 -0
- package/dist/records-package.js +300 -0
- package/dist/records-package.js.map +1 -0
- package/package.json +3 -2
- package/src/distributed-records.ts +251 -0
- package/src/host-profiles.ts +13 -0
- package/src/hosting-suitability.ts +11 -1
- package/src/index.ts +5 -0
- package/src/measurement-grid.ts +97 -0
- package/src/memory-bound.ts +55 -0
- package/src/probe-judge.ts +25 -1
- package/src/probe-merge.ts +89 -0
- package/src/probe-output.ts +13 -0
- package/src/probe-runner.ts +75 -13
- package/src/qualification-record.ts +44 -2
- package/src/qualify-cli.ts +32 -1
- package/src/qualify-declarations.ts +17 -0
- package/src/qualify-options.ts +11 -1
- package/src/qualify-sync.ts +175 -16
- package/src/records-package.ts +333 -0
- package/tools/{ai-trace-commit-check.sh → commit-message-check.sh} +30 -28
- package/tools/{ai-trace-patterns.json → commit-message-patterns.json} +1 -1
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
// Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
|
|
3
|
+
/**
|
|
4
|
+
* The distributed layer: qualification records another device of the same machine class wrote for a plugin set,
|
|
5
|
+
* installed read-only under `<dir>/<host class>/<set>/` beside the set's `provenance.json`. plugin-qualify-sync reads
|
|
6
|
+
* it before measuring a plugin, and takes a plugin from it instead of measuring it only when every file the record
|
|
7
|
+
* names hashes, here, as it did where it was measured. The name of a plugin finds its record and never decides
|
|
8
|
+
* anything: a record is never trusted by name alone.
|
|
9
|
+
*
|
|
10
|
+
* What applies is copied into the device's own `qualification_dir`, the record with the set's provenance added and
|
|
11
|
+
* its probe output byte for byte, and the plugin's pin is the one that probe output holds. From then on the sync
|
|
12
|
+
* treats the plugin as one it measured.
|
|
13
|
+
*/
|
|
14
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
15
|
+
import { join, resolve } from "node:path";
|
|
16
|
+
|
|
17
|
+
import { clapPinUri, pinVerdictOf, type PinEntry } from "./pins-file.js";
|
|
18
|
+
import { integrityOfProbeOutput } from "./probe-judge.js";
|
|
19
|
+
import { readProbeOutput, sha256Hex } from "./probe-output.js";
|
|
20
|
+
import { QUALIFICATION_RECORD_SCHEMA, type QualificationRecord, type RecordVerdict } from "./qualification-record.js";
|
|
21
|
+
import type { FileDigest, SyncItem, SyncOutcome, SyncPlugin } from "./qualify-sync.js";
|
|
22
|
+
import type { SuitabilityRating } from "./rating.js";
|
|
23
|
+
import { writeFileWhole } from "./whole-file.js";
|
|
24
|
+
|
|
25
|
+
/** Where a system's packages install the distributed records. */
|
|
26
|
+
export const DISTRIBUTED_DIR_SYSTEM = "/usr/share/omx/qualification";
|
|
27
|
+
|
|
28
|
+
/** The file of a set that says where its records come from. */
|
|
29
|
+
export const DISTRIBUTED_PROVENANCE_FILE = "provenance.json";
|
|
30
|
+
export const DISTRIBUTED_PROVENANCE_SCHEMA = "plugin-qualify.distributed-provenance/1";
|
|
31
|
+
|
|
32
|
+
/** The first plugin-qualify that reads the layer: what a package of records depends on. */
|
|
33
|
+
export const DISTRIBUTED_LAYER_SINCE = "0.17.0";
|
|
34
|
+
|
|
35
|
+
/** Where the records of a set come from. The keys below are always there; the rest is what the builder was given. */
|
|
36
|
+
export interface DistributedProvenance {
|
|
37
|
+
readonly schema: typeof DISTRIBUTED_PROVENANCE_SCHEMA;
|
|
38
|
+
readonly set: string;
|
|
39
|
+
readonly hostClass: string;
|
|
40
|
+
/** The package that installed the set. */
|
|
41
|
+
readonly package: { readonly name: string; readonly version: string };
|
|
42
|
+
/** The day the package was built (YYYY-MM-DD). */
|
|
43
|
+
readonly date: string;
|
|
44
|
+
readonly [key: string]: unknown;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The device tree's model file, where a board names itself. */
|
|
48
|
+
export const BOARD_MODEL_FILE = "/proc/device-tree/model";
|
|
49
|
+
|
|
50
|
+
/** The board's own model, or `undefined` on a machine without a device tree. */
|
|
51
|
+
export function readBoardModel(): string | undefined {
|
|
52
|
+
try {
|
|
53
|
+
return readFileSync(BOARD_MODEL_FILE, "utf8");
|
|
54
|
+
} catch {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The host class of a board the layer knows by its device-tree model: `rpi5` for a Raspberry Pi 5 or its compute module. */
|
|
60
|
+
export function hostClassOfBoard(model: string | undefined): string | undefined {
|
|
61
|
+
if (model === undefined) return undefined;
|
|
62
|
+
const m = /^Raspberry Pi (?:Compute Module )?(\d)(?:\s|$)/.exec(model.replace(/\0/g, "").trim());
|
|
63
|
+
return m === null ? undefined : `rpi${m[1]!}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** One distributed record, read: where it is, what it says, and the set it came with. */
|
|
67
|
+
export interface DistributedRecord {
|
|
68
|
+
readonly path: string;
|
|
69
|
+
readonly record: QualificationRecord;
|
|
70
|
+
readonly set: string;
|
|
71
|
+
readonly provenance: DistributedProvenance;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The layer as one run reads it: the records of the host class, by what finds them. */
|
|
75
|
+
export interface DistributedLayer {
|
|
76
|
+
/** By the `.clap` path the record measured, made absolute. */
|
|
77
|
+
readonly clap: ReadonlyMap<string, readonly DistributedRecord[]>;
|
|
78
|
+
/** By the LV2 plugin's uri. */
|
|
79
|
+
readonly lv2: ReadonlyMap<string, readonly DistributedRecord[]>;
|
|
80
|
+
readonly sets: readonly string[];
|
|
81
|
+
readonly records: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const EMPTY_LAYER: DistributedLayer = { clap: new Map(), lv2: new Map(), sets: [], records: 0 };
|
|
85
|
+
|
|
86
|
+
const RECORD_SUFFIX = ".qualification.json";
|
|
87
|
+
|
|
88
|
+
const names = (dir: string): string[] => {
|
|
89
|
+
try {
|
|
90
|
+
return readdirSync(dir).sort();
|
|
91
|
+
} catch {
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The records of one host class under each directory, in the order given. A set without a provenance that reads, or
|
|
98
|
+
* whose provenance names another set or class, is not read; a record that does not read as a record of this schema is
|
|
99
|
+
* named and left out.
|
|
100
|
+
*/
|
|
101
|
+
export function readDistributedLayer(dirs: readonly string[], hostClass: string, log: (line: string) => void = () => {}): DistributedLayer {
|
|
102
|
+
const clap = new Map<string, DistributedRecord[]>();
|
|
103
|
+
const lv2 = new Map<string, DistributedRecord[]>();
|
|
104
|
+
const sets: string[] = [];
|
|
105
|
+
let records = 0;
|
|
106
|
+
const add = (map: Map<string, DistributedRecord[]>, key: string, r: DistributedRecord): void => {
|
|
107
|
+
map.set(key, [...(map.get(key) ?? []), r]);
|
|
108
|
+
};
|
|
109
|
+
for (const base of dirs) {
|
|
110
|
+
const classDir = join(base, hostClass);
|
|
111
|
+
for (const set of names(classDir)) {
|
|
112
|
+
const dir = join(classDir, set);
|
|
113
|
+
let provenance: DistributedProvenance;
|
|
114
|
+
try {
|
|
115
|
+
provenance = JSON.parse(readFileSync(join(dir, DISTRIBUTED_PROVENANCE_FILE), "utf8")) as DistributedProvenance;
|
|
116
|
+
} catch (e) {
|
|
117
|
+
if ((e as NodeJS.ErrnoException).code === "ENOTDIR") continue;
|
|
118
|
+
log(`distributed: ${dir}: ${(e as NodeJS.ErrnoException).code === "ENOENT" ? `no ${DISTRIBUTED_PROVENANCE_FILE}` : `${DISTRIBUTED_PROVENANCE_FILE} does not read`}, not read`);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (provenance.schema !== DISTRIBUTED_PROVENANCE_SCHEMA || provenance.set !== set || provenance.hostClass !== hostClass) {
|
|
122
|
+
log(`distributed: ${dir}: its ${DISTRIBUTED_PROVENANCE_FILE} is not of set ${set} and host class ${hostClass} (${DISTRIBUTED_PROVENANCE_SCHEMA}), not read`);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
sets.push(set);
|
|
126
|
+
for (const name of names(dir)) {
|
|
127
|
+
if (!name.endsWith(RECORD_SUFFIX)) continue;
|
|
128
|
+
const path = join(dir, name);
|
|
129
|
+
let record: QualificationRecord;
|
|
130
|
+
try {
|
|
131
|
+
record = JSON.parse(readFileSync(path, "utf8")) as QualificationRecord;
|
|
132
|
+
} catch {
|
|
133
|
+
log(`distributed: ${path}: does not read, left out`);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (record.schema !== QUALIFICATION_RECORD_SCHEMA || typeof record.binary?.path !== "string" || !Array.isArray(record.binary.files) || record.binary.files.length === 0) {
|
|
137
|
+
log(`distributed: ${path}: not a ${QUALIFICATION_RECORD_SCHEMA} with its binary's files, left out`);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const r: DistributedRecord = { path, record, set, provenance };
|
|
141
|
+
records++;
|
|
142
|
+
if (record.plugin.format === "clap") add(clap, resolve(record.binary.path), r);
|
|
143
|
+
else if (record.plugin.format === "lv2") add(lv2, record.plugin.id, r);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return { clap, lv2, sets, records };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The uri a record's plugin has on this machine: the installed `.clap`'s path and the id, or the LV2 uri. */
|
|
151
|
+
export function distributedUri(item: SyncItem, r: DistributedRecord): string {
|
|
152
|
+
return item.format === "clap" ? clapPinUri(join(item.root, item.files[0]!), `urn:clap:${r.record.plugin.id}`) : r.record.plugin.id;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** What the sync's own judgment would make of the record's verdict for the host, as `outcomeOf` reads a run. */
|
|
156
|
+
function outcomeOfVerdict(v: RecordVerdict): SyncOutcome {
|
|
157
|
+
if (!v.measured) return v.deciding === "probe.crashed" ? "crashed" : v.deciding === "probe.timeout" ? "hung" : "unmeasured";
|
|
158
|
+
if (v.rating === "suitable" || v.rating === "conditional") return "qualified";
|
|
159
|
+
return v.rating === "unsuitable" ? "refused" : "unmeasured";
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** What the layer makes of one item. */
|
|
163
|
+
export type DistributedMatch =
|
|
164
|
+
/** No distributed record names the plugin: it is measured, and nothing is said. */
|
|
165
|
+
| { readonly status: "none" }
|
|
166
|
+
/** Every reason a record found was not taken, one line each; the plugin is measured. */
|
|
167
|
+
| { readonly status: "ignored"; readonly lines: readonly string[] }
|
|
168
|
+
| { readonly status: "applies"; readonly set: string; readonly records: readonly DistributedRecord[]; readonly files: readonly string[] };
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Whether the item is taken from the layer. The records that find it are tried one set at a time, in the order the
|
|
172
|
+
* layer read them. Of a set, only the records whose every file is installed with the SHA-256 it recorded (computed
|
|
173
|
+
* here by `digest`) count: a record of other bytes is another build of the plugin and is passed over. The set applies
|
|
174
|
+
* when at least one record of these bytes holds a verdict for `host`, and none of those verdicts is one the source
|
|
175
|
+
* could not measure; a set with no record of these bytes is a binary that differs.
|
|
176
|
+
*/
|
|
177
|
+
export function matchDistributed(
|
|
178
|
+
layer: DistributedLayer,
|
|
179
|
+
item: SyncItem,
|
|
180
|
+
host: string,
|
|
181
|
+
digest: (files: readonly string[]) => readonly FileDigest[] | undefined,
|
|
182
|
+
): DistributedMatch {
|
|
183
|
+
const found = item.format === "clap" ? layer.clap.get(resolve(join(item.root, item.files[0]!))) : layer.lv2.get(item.uri ?? "");
|
|
184
|
+
if (found === undefined || found.length === 0) return { status: "none" };
|
|
185
|
+
const lines: string[] = [];
|
|
186
|
+
const say = (line: string): void => {
|
|
187
|
+
if (!lines.includes(line)) lines.push(line);
|
|
188
|
+
};
|
|
189
|
+
for (const set of [...new Set(found.map((r) => r.set))]) {
|
|
190
|
+
// a set may hold records of earlier bytes of the plugin beside the ones of these: only the ones of these bytes count
|
|
191
|
+
const records = found.filter((r) => {
|
|
192
|
+
if (r.set !== set || r.record.plugin.format !== item.format) return false;
|
|
193
|
+
const wanted = r.record.binary.files;
|
|
194
|
+
const now = digest(wanted.map((f) => f.path));
|
|
195
|
+
return now !== undefined && wanted.every((f) => now.find((d) => d.path === f.path)?.sha256 === f.sha256);
|
|
196
|
+
});
|
|
197
|
+
if (records.length === 0) {
|
|
198
|
+
say(`distributed: ${distributedUri(item, found.find((r) => r.set === set)!)}: binary differs`);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
const judged = records.filter((r) => r.record.verdicts.some((v) => v.host === host));
|
|
202
|
+
if (judged.length === 0) {
|
|
203
|
+
say(`distributed: ${distributedUri(item, records[0]!)}: no verdict for ${host} in ${set}`);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
const unmeasured = judged.find((r) => outcomeOfVerdict(r.record.verdicts.find((v) => v.host === host)!) === "unmeasured");
|
|
207
|
+
if (unmeasured !== undefined) {
|
|
208
|
+
say(`distributed: ${distributedUri(item, unmeasured)}: unmeasured in ${set}, measured here`);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
return { status: "applies", set, records, files: [...new Set(records.flatMap((r) => r.record.binary.files.map((f) => f.path)))] };
|
|
212
|
+
}
|
|
213
|
+
return { status: "ignored", lines };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The records of a match copied into `qualificationDir` and the plugins they make: the probe output first, byte for
|
|
218
|
+
* byte and read back through this version's reader, its SHA-256 the one the record names; then the record with the
|
|
219
|
+
* set's provenance added. A record whose probe output is missing, altered or unreadable throws, naming it, and the
|
|
220
|
+
* caller measures the plugin instead. Records of the other tier (no verdict for `host`) are copied and make no plugin,
|
|
221
|
+
* as the sweep writes them.
|
|
222
|
+
*/
|
|
223
|
+
export function applyDistributed(qualificationDir: string, item: SyncItem, match: Extract<DistributedMatch, { status: "applies" }>, host: string): SyncPlugin[] {
|
|
224
|
+
const copies = match.records.map((r) => {
|
|
225
|
+
const probeName = r.record.probe.file;
|
|
226
|
+
const probePath = join(r.path, "..", probeName);
|
|
227
|
+
let bytes: Buffer;
|
|
228
|
+
try {
|
|
229
|
+
bytes = readFileSync(probePath);
|
|
230
|
+
} catch (e) {
|
|
231
|
+
throw new Error(`${probeName}: ${(e as NodeJS.ErrnoException).code ?? String(e)}`);
|
|
232
|
+
}
|
|
233
|
+
if (sha256Hex(bytes) !== r.record.probe.sha256) throw new Error(`${probeName}: not the probe output the record names (its SHA-256 differs)`);
|
|
234
|
+
const read = readProbeOutput(bytes.toString("utf8"));
|
|
235
|
+
if (!read.ok) throw new Error(`${probeName}: ${read.code}: ${read.problem}`);
|
|
236
|
+
return { r, bytes, output: read.output };
|
|
237
|
+
});
|
|
238
|
+
const plugins: SyncPlugin[] = [];
|
|
239
|
+
for (const { r, bytes, output } of copies) {
|
|
240
|
+
writeFileWhole(join(qualificationDir, r.record.probe.file), bytes);
|
|
241
|
+
const recordPath = join(qualificationDir, r.path.slice(r.path.lastIndexOf("/") + 1));
|
|
242
|
+
writeFileWhole(recordPath, `${JSON.stringify({ ...r.record, provenance: r.provenance }, null, 2)}\n`);
|
|
243
|
+
const v = r.record.verdicts.find((x) => x.host === host);
|
|
244
|
+
if (v === undefined) continue;
|
|
245
|
+
const word = v.measured ? pinVerdictOf(v.rating as SuitabilityRating) : undefined;
|
|
246
|
+
const pin: PinEntry = { uri: distributedUri(item, r), integrity: integrityOfProbeOutput(output), ...(word === undefined ? {} : { verdict: word }) };
|
|
247
|
+
const outcome = outcomeOfVerdict(v);
|
|
248
|
+
plugins.push({ uri: pin.uri, outcome, ...(v.deciding === undefined ? {} : { code: v.deciding }), pin, record: recordPath });
|
|
249
|
+
}
|
|
250
|
+
return plugins;
|
|
251
|
+
}
|
package/src/host-profiles.ts
CHANGED
|
@@ -24,6 +24,11 @@ import type { HostProfile } from "./measurement/format.js";
|
|
|
24
24
|
* the console hands the same array to the in-process LV2 table at `omx_host_backend_configure`,
|
|
25
25
|
* which builds one provider per URI and refuses a URI it cannot provide; there is no C copy (the
|
|
26
26
|
* console's native test reads this export).
|
|
27
|
+
*
|
|
28
|
+
* Equal to omx-clap-core's `LV2_CORE_PROVIDED_INIT` (`lv2/core/lv2_core.h`), same URIs in the same
|
|
29
|
+
* order: the original seven, then the three rtmempool URIs (the header's `LV2_RTSAFE_MEMORY_POOL__Pool`,
|
|
30
|
+
* the `#Pool` spelling some TTLs require, and the deprecated v1 URI), the three state-path features,
|
|
31
|
+
* then uri-map and event (omx-clap-core #94).
|
|
27
32
|
*/
|
|
28
33
|
export const OMX_INPROCESS_FEATURES: readonly string[] = [
|
|
29
34
|
"http://lv2plug.in/ns/ext/urid#map",
|
|
@@ -33,6 +38,14 @@ export const OMX_INPROCESS_FEATURES: readonly string[] = [
|
|
|
33
38
|
"http://lv2plug.in/ns/ext/worker#schedule",
|
|
34
39
|
"http://lv2plug.in/ns/ext/state#loadDefaultState",
|
|
35
40
|
"http://lv2plug.in/ns/ext/log#log",
|
|
41
|
+
"http://kxstudio.sf.net/ns/lv2ext/rtmempoolPool",
|
|
42
|
+
"http://kxstudio.sf.net/ns/lv2ext/rtmempool#Pool",
|
|
43
|
+
"http://home.gna.org/lv2dynparam/rtmempool/v1",
|
|
44
|
+
"http://lv2plug.in/ns/ext/state#makePath",
|
|
45
|
+
"http://lv2plug.in/ns/ext/state#mapPath",
|
|
46
|
+
"http://lv2plug.in/ns/ext/state#freePath",
|
|
47
|
+
"http://lv2plug.in/ns/ext/uri-map",
|
|
48
|
+
"http://lv2plug.in/ns/ext/event",
|
|
36
49
|
];
|
|
37
50
|
|
|
38
51
|
/**
|
|
@@ -133,6 +133,12 @@ const QUANTUM_PINNED_FEATURES: readonly string[] = [
|
|
|
133
133
|
"http://lv2plug.in/ns/ext/buf-size#powerOf2BlockLength",
|
|
134
134
|
];
|
|
135
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Promises the adapter keeps independent of what the profile's in-process list offers: the
|
|
138
|
+
* adapter's ports are its own memory, so `lv2:inPlaceBroken` always holds (omx-clap-core #94).
|
|
139
|
+
*/
|
|
140
|
+
const ALWAYS_MET_FEATURES: readonly string[] = ["http://lv2plug.in/ns/lv2core#inPlaceBroken"];
|
|
141
|
+
|
|
136
142
|
/** The verdict's message codes, declared in their union list file. */
|
|
137
143
|
import { HOSTING_CODE } from "./hosting-codes.js";
|
|
138
144
|
export { HOSTING_CODE };
|
|
@@ -685,7 +691,11 @@ export function featureReasons(
|
|
|
685
691
|
);
|
|
686
692
|
}
|
|
687
693
|
const missing = required.filter(
|
|
688
|
-
(f) =>
|
|
694
|
+
(f) =>
|
|
695
|
+
!provided.includes(f) &&
|
|
696
|
+
!refused.includes(f) &&
|
|
697
|
+
!QUANTUM_PINNED_FEATURES.includes(f) &&
|
|
698
|
+
!ALWAYS_MET_FEATURES.includes(f),
|
|
689
699
|
);
|
|
690
700
|
if (missing.length > 0) {
|
|
691
701
|
out.push(
|
package/src/index.ts
CHANGED
|
@@ -24,9 +24,14 @@ export * from "./probe-output.js";
|
|
|
24
24
|
export * from "./probe-judge.js";
|
|
25
25
|
export * from "./qualification-record.js";
|
|
26
26
|
export * from "./probe-runner.js";
|
|
27
|
+
export * from "./measurement-grid.js";
|
|
28
|
+
export * from "./memory-bound.js";
|
|
29
|
+
export * from "./probe-merge.js";
|
|
27
30
|
export * from "./judge-cli.js";
|
|
28
31
|
export * from "./whole-file.js";
|
|
29
32
|
export * from "./qualify-sync.js";
|
|
33
|
+
export * from "./distributed-records.js";
|
|
34
|
+
export * from "./records-package.js";
|
|
30
35
|
export * from "./host-admission.js";
|
|
31
36
|
export * from "./history-file.js";
|
|
32
37
|
export * from "./history-adjust.js";
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
// Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
|
|
3
|
+
/**
|
|
4
|
+
* The measurement grid (#193): the cells, each one rate and one quantum, a plugin is measured at, and the runs of the
|
|
5
|
+
* probe runner that measure them. A host is judged at its own cell, so the grid a run takes is chosen from the hosts it
|
|
6
|
+
* judges; README "The measurement grid" states the three ways of choosing it and the order the cells are taken in.
|
|
7
|
+
*
|
|
8
|
+
* Pure: no file, no process. `probe-runner.ts` runs what `runnerInvocations` plans.
|
|
9
|
+
*/
|
|
10
|
+
import { GRID_CONTINUITY_QUANTUM } from "./qualify-declarations.js";
|
|
11
|
+
|
|
12
|
+
/** One cell of the grid. */
|
|
13
|
+
export interface GridCell {
|
|
14
|
+
readonly rate: number;
|
|
15
|
+
readonly quantum: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** How the grid is chosen: the hosts' cells, the full product of the rates and quanta, or the cells named. */
|
|
19
|
+
export type GridSpec = { readonly kind: "hosts" } | { readonly kind: "full" } | { readonly kind: "cells"; readonly cells: readonly GridCell[] };
|
|
20
|
+
|
|
21
|
+
export const GRID_HOSTS: GridSpec = { kind: "hosts" };
|
|
22
|
+
export const GRID_FULL: GridSpec = { kind: "full" };
|
|
23
|
+
|
|
24
|
+
export const cellKey = (c: GridCell): string => `${String(c.rate)}/${String(c.quantum)}`;
|
|
25
|
+
|
|
26
|
+
const positive = (t: string): number | undefined => {
|
|
27
|
+
const n = Number(t);
|
|
28
|
+
return /^\d+$/.test(t) && Number.isSafeInteger(n) && n > 0 ? n : undefined;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** `hosts`, `full`, or `<rate>/<quantum>,...`; anything else is refused with what was wrong. */
|
|
32
|
+
export function parseGridSpec(text: string): { ok: true; spec: GridSpec } | { ok: false; error: string } {
|
|
33
|
+
const t = text.trim();
|
|
34
|
+
if (t === "hosts") return { ok: true, spec: GRID_HOSTS };
|
|
35
|
+
if (t === "full") return { ok: true, spec: GRID_FULL };
|
|
36
|
+
const cells: GridCell[] = [];
|
|
37
|
+
for (const part of t.split(",").map((p) => p.trim())) {
|
|
38
|
+
const m = /^(\d+)\/(\d+)$/.exec(part);
|
|
39
|
+
const rate = m === null ? undefined : positive(m[1]!);
|
|
40
|
+
const quantum = m === null ? undefined : positive(m[2]!);
|
|
41
|
+
if (rate === undefined || quantum === undefined) {
|
|
42
|
+
return { ok: false, error: `the grid is hosts, full, or cells as <rate>/<quantum>, comma-separated; "${part}" is none of them` };
|
|
43
|
+
}
|
|
44
|
+
cells.push({ rate, quantum });
|
|
45
|
+
}
|
|
46
|
+
return { ok: true, spec: { kind: "cells", cells: unique(cells) } };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function unique(cells: readonly GridCell[]): GridCell[] {
|
|
50
|
+
const seen = new Set<string>();
|
|
51
|
+
return cells.filter((c) => (seen.has(cellKey(c)) ? false : (seen.add(cellKey(c)), true)));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const byRateThenQuantum = (a: GridCell, b: GridCell): number => a.rate - b.rate || a.quantum - b.quantum;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The cells of a grid, in the order they are taken: for `hosts`, each host's own cell in the order the hosts are given,
|
|
58
|
+
* then `GRID_CONTINUITY_QUANTUM` at every rate; for `full`, those cells first as well, then the rest of the product by
|
|
59
|
+
* rate and quantum; for named cells, as named. A cell is listed once.
|
|
60
|
+
*/
|
|
61
|
+
export function gridCells(
|
|
62
|
+
spec: GridSpec,
|
|
63
|
+
context: { readonly rates: readonly number[]; readonly quanta: readonly number[]; readonly hosts: readonly GridCell[] },
|
|
64
|
+
): GridCell[] {
|
|
65
|
+
if (spec.kind === "cells") return unique(spec.cells);
|
|
66
|
+
const continuity = [...context.rates].sort((a, b) => a - b).map((rate) => ({ rate, quantum: GRID_CONTINUITY_QUANTUM }));
|
|
67
|
+
const lead = unique([...context.hosts, ...continuity]);
|
|
68
|
+
if (spec.kind === "hosts") return lead;
|
|
69
|
+
const product = context.rates.flatMap((rate) => context.quanta.map((quantum) => ({ rate, quantum }))).sort(byRateThenQuantum);
|
|
70
|
+
const inProduct = new Set(product.map(cellKey));
|
|
71
|
+
// the full grid is the product: a host's cell outside --rates x --quanta is not part of it
|
|
72
|
+
return unique([...lead.filter((c) => inProduct.has(cellKey(c))), ...product]);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The first `budget` cells, or every one when the budget is absent or 0. */
|
|
76
|
+
export function budgetCells(cells: readonly GridCell[], budget: number | undefined): GridCell[] {
|
|
77
|
+
return budget === undefined || budget === 0 ? [...cells] : cells.slice(0, budget);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** One run of the probe runner: the rates it is given, its quantum, and the cells it stands for. */
|
|
81
|
+
export interface RunnerInvocation {
|
|
82
|
+
readonly rates: readonly number[];
|
|
83
|
+
readonly quantum: number;
|
|
84
|
+
readonly cells: readonly GridCell[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The runs that measure `cells`. A CLAP plugin is run once per quantum, at every rate of the grid at that quantum (the
|
|
89
|
+
* prober takes a list of rates and one block size). An LV2 plugin is run once per cell: plugin-hostd's runner measures
|
|
90
|
+
* an LV2 plugin at one rate a run, the highest of the rates it is given, so a list would measure only that one.
|
|
91
|
+
*/
|
|
92
|
+
export function runnerInvocations(format: string, cells: readonly GridCell[]): RunnerInvocation[] {
|
|
93
|
+
if (format !== "clap") return cells.map((c) => ({ rates: [c.rate], quantum: c.quantum, cells: [c] }));
|
|
94
|
+
const byQuantum = new Map<number, GridCell[]>();
|
|
95
|
+
for (const c of cells) byQuantum.set(c.quantum, [...(byQuantum.get(c.quantum) ?? []), c]);
|
|
96
|
+
return [...byQuantum.entries()].map(([quantum, at]) => ({ rates: [...new Set(at.map((c) => c.rate))].sort((a, b) => a - b), quantum, cells: at }));
|
|
97
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
// Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
|
|
3
|
+
/**
|
|
4
|
+
* The memory bound of a run (#193). A sampler loading its library can take all of a small machine's memory; unbounded,
|
|
5
|
+
* the kernel's out-of-memory killer ends it (or something else) and the run is a crash nobody can tell from a bug. So
|
|
6
|
+
* every run is bounded: the runner's data limit (`--data-bytes`, `RLIMIT_DATA` on the measuring child), with the
|
|
7
|
+
* interposer told to exit `MEMORY_BOUND_EXIT_STATUS` the moment an allocation is refused under it.
|
|
8
|
+
*
|
|
9
|
+
* A run is out of memory when one of its steps says the child exited with that status, or was killed by a `SIGKILL`
|
|
10
|
+
* the runner did not send (its own deadline kill is `probe.timeout`, never `probe.crashed`): the kernel's killer.
|
|
11
|
+
*/
|
|
12
|
+
import { totalmem } from "node:os";
|
|
13
|
+
|
|
14
|
+
import type { ProbeOutput } from "./probe-output.js";
|
|
15
|
+
import { MEMORY_BOUND_EXIT_STATUS, MEMORY_LIMIT_RAM_FRACTION } from "./qualify-declarations.js";
|
|
16
|
+
|
|
17
|
+
/** How the bound is applied: the runner's data limit on the child. */
|
|
18
|
+
export const MEMORY_BOUND_MECHANISM = "rlimit-data";
|
|
19
|
+
|
|
20
|
+
/** The outcome of a run, and of each of its cells, that outgrew the bound. */
|
|
21
|
+
export const PROBE_OOM = "probe.oom";
|
|
22
|
+
|
|
23
|
+
export interface MemoryBound {
|
|
24
|
+
readonly bytes: number;
|
|
25
|
+
readonly mechanism: typeof MEMORY_BOUND_MECHANISM;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const UNITS: Readonly<Record<string, number>> = { "": 1, K: 2 ** 10, M: 2 ** 20, G: 2 ** 30, T: 2 ** 40 };
|
|
29
|
+
|
|
30
|
+
/** `off`, a number of bytes, or one with a K, M, G or T suffix (powers of two: K is 2 ** 10 bytes). */
|
|
31
|
+
export function parseMemoryLimit(text: string): { ok: true; bytes: number | "off" } | { ok: false; error: string } {
|
|
32
|
+
const t = text.trim();
|
|
33
|
+
if (t === "off") return { ok: true, bytes: "off" };
|
|
34
|
+
const m = /^(\d+)([KMGT]?)(?:i?B)?$/i.exec(t);
|
|
35
|
+
const bytes = m === null ? NaN : Number(m[1]) * UNITS[m[2]!.toUpperCase()]!;
|
|
36
|
+
if (!Number.isSafeInteger(bytes) || bytes <= 0) return { ok: false, error: `the memory limit is off, or bytes as 2147483648, 2048M or 2G; "${t}" is neither` };
|
|
37
|
+
return { ok: true, bytes };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The default bound: `MEMORY_LIMIT_RAM_FRACTION` of the machine's RAM. */
|
|
41
|
+
export function defaultMemoryLimit(ramBytes: number = totalmem()): number {
|
|
42
|
+
return Math.floor(ramBytes * MEMORY_LIMIT_RAM_FRACTION);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The bound a setting names: `undefined` (the default), `off`, or a size. */
|
|
46
|
+
export function memoryBoundOf(setting: number | "off" | undefined, ramBytes?: number): MemoryBound | undefined {
|
|
47
|
+
if (setting === "off") return undefined;
|
|
48
|
+
return { bytes: setting ?? defaultMemoryLimit(ramBytes), mechanism: MEMORY_BOUND_MECHANISM };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Whether a run under `bound` ended out of memory: the interposer's exit status, or a SIGKILL that was no deadline's. */
|
|
52
|
+
export function outOfMemory(output: ProbeOutput, bound: MemoryBound | undefined): boolean {
|
|
53
|
+
if (bound === undefined) return false;
|
|
54
|
+
return output.steps.some((s) => s.exitStatus === MEMORY_BOUND_EXIT_STATUS || (s.signal === "SIGKILL" && s.outcome === "probe.crashed"));
|
|
55
|
+
}
|
package/src/probe-judge.ts
CHANGED
|
@@ -38,6 +38,7 @@ import { PROBE_NOT_ATTEMPTED, PROBE_OK, type ProbeGridPoint, type ProbeOutput }
|
|
|
38
38
|
import type { QualifyVerdict } from "./qualify-cli.js";
|
|
39
39
|
import { RATING_SEVERITY, worstRating, type SuitabilityRating } from "./rating.js";
|
|
40
40
|
import type { IntegrityMeasurement, VoicesHeld } from "./measurement/format.js";
|
|
41
|
+
import { PROBE_OOM } from "./memory-bound.js";
|
|
41
42
|
|
|
42
43
|
/** Facts of the descriptor, which the probe output repeats none of. */
|
|
43
44
|
export interface JudgeFacts {
|
|
@@ -197,8 +198,25 @@ function decidingOf(reasons: readonly RecordReason[], rating: SuitabilityRating)
|
|
|
197
198
|
return [...reasons].sort((a, b) => RATING_SEVERITY[b.rating] - RATING_SEVERITY[a.rating]).find((r) => r.rating === rating);
|
|
198
199
|
}
|
|
199
200
|
|
|
201
|
+
/** The grid point at a host's own cell, whatever its outcome; `undefined` when the grid does not hold that cell. */
|
|
202
|
+
function cellOf(output: ProbeOutput, profile: HostProfile): ProbeGridPoint | undefined {
|
|
203
|
+
return output.grid.find((p) => p.rate === profile.cost.rate && p.quantum === profile.cost.quantum);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const memoryParams = (p: ProbeGridPoint): Record<string, number | string> =>
|
|
207
|
+
p.memoryLimit === undefined ? {} : { memoryLimitBytes: p.memoryLimit.bytes, memoryLimitMechanism: p.memoryLimit.mechanism };
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The cost reasons at the host's rate and quantum. A cell that was measured is read against the host's budget; a cell
|
|
211
|
+
* whose run did not end well is `unknown` with that run's outcome (`probe.oom`, `probe.crashed`); only a cell the grid
|
|
212
|
+
* does not hold is `hosting.cost.unmeasured-at-rate-and-quantum`.
|
|
213
|
+
*/
|
|
200
214
|
function costOf(output: ProbeOutput, profile: HostProfile): HostJudgment["cost"] {
|
|
201
215
|
const { rate, quantum } = profile.cost;
|
|
216
|
+
const cell = cellOf(output, profile);
|
|
217
|
+
if (cell !== undefined && cell.outcome !== PROBE_OK && cell.outcome !== PROBE_NOT_ATTEMPTED) {
|
|
218
|
+
return { rate, quantum, reasons: [{ dimension: "cost", rating: "unknown", code: cell.outcome, params: { rate, quantum, ...memoryParams(cell) } }] };
|
|
219
|
+
}
|
|
202
220
|
const reasons = hostingCostReasons(
|
|
203
221
|
{ cpuCost: { perRateQuantum: gridCostReadings(output.grid, PROBE_OK) } },
|
|
204
222
|
{ rate, quantum, profile },
|
|
@@ -215,7 +233,8 @@ function costOf(output: ProbeOutput, profile: HostProfile): HostJudgment["cost"]
|
|
|
215
233
|
*/
|
|
216
234
|
function failedOutcome(output: ProbeOutput): string | undefined {
|
|
217
235
|
if (output.run.outcome !== PROBE_OK) return output.run.outcome;
|
|
218
|
-
|
|
236
|
+
// a cell that outgrew the memory bound is a recorded outcome of that cell, judged per host below, not the run's end
|
|
237
|
+
return output.grid.find((p) => p.outcome !== PROBE_OK && p.outcome !== PROBE_NOT_ATTEMPTED && p.outcome !== PROBE_OOM)?.outcome;
|
|
219
238
|
}
|
|
220
239
|
|
|
221
240
|
/**
|
|
@@ -257,6 +276,11 @@ export function judgeProbeOutput(output: ProbeOutput, profiles: readonly HostPro
|
|
|
257
276
|
const notes = noteSweepOfProbeOutput(output, rt);
|
|
258
277
|
reasons = [...rtSafetyReasons(rt), ...noteSweepReasons(notes, facts.noteInputs ?? (notes.applicable ? 1 : 0))].map(asRecordReason);
|
|
259
278
|
}
|
|
279
|
+
// the plugin did not run within the memory bound at this host's own rate and quantum: not hostable there as measured
|
|
280
|
+
const cell = cellOf(output, profile);
|
|
281
|
+
if (cell?.outcome === PROBE_OOM) {
|
|
282
|
+
reasons = [...reasons, { dimension: "memory", rating: "unknown", code: PROBE_OOM, params: { rate: cell.rate, quantum: cell.quantum, ...memoryParams(cell) } }];
|
|
283
|
+
}
|
|
260
284
|
const admission = facts.admissions?.[profile.id];
|
|
261
285
|
if (admission !== undefined && admittedHostOf(profile) !== undefined) {
|
|
262
286
|
if (!admission.measured || !admission.admitted) {
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
// Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
|
|
3
|
+
/**
|
|
4
|
+
* The runs of one plugin and one offer, made one probe output (#193). The grid takes several runs of the probe runner
|
|
5
|
+
* (one per quantum for a CLAP plugin, one per cell for an LV2 one); the judge and the record read one document. The
|
|
6
|
+
* merge is a translation, never a judgment:
|
|
7
|
+
*
|
|
8
|
+
* - the grid is every run's points, and, for a run that did not end `probe.ok`, a point at each of its cells that holds
|
|
9
|
+
* none, carrying the run's outcome (and, for `probe.oom`, the bound it ran under), so no cell is silently absent;
|
|
10
|
+
* - the steps are every run's, in order; the run's time is the sum, and it is complete when every run was;
|
|
11
|
+
* - the run's outcome is the first that was not `probe.ok` among the runs that did not outgrow the bound; a run that
|
|
12
|
+
* outgrew it leaves its cells `probe.oom` and the rest are still judged; when every run did, it is `probe.oom`;
|
|
13
|
+
* - `run.parts` names each run: its id, its outcome, its rates and quantum, its time.
|
|
14
|
+
*
|
|
15
|
+
* One run that ended well and needs nothing added is kept as the runner wrote it, byte for byte.
|
|
16
|
+
*/
|
|
17
|
+
import { outOfMemory, PROBE_OOM, type MemoryBound } from "./memory-bound.js";
|
|
18
|
+
import { cellKey, type RunnerInvocation } from "./measurement-grid.js";
|
|
19
|
+
import { PROBE_OK, readProbeOutput, type ProbeOutput } from "./probe-output.js";
|
|
20
|
+
|
|
21
|
+
/** One run of the runner, as it was read. */
|
|
22
|
+
export interface ProbeRunPart {
|
|
23
|
+
readonly bytes: Buffer;
|
|
24
|
+
readonly output: ProbeOutput;
|
|
25
|
+
readonly invocation: RunnerInvocation;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class ProbeMergeError extends Error {
|
|
29
|
+
constructor(message: string) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = "ProbeMergeError";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type Json = Record<string, unknown>;
|
|
36
|
+
|
|
37
|
+
export function mergeProbeRuns(parts: readonly ProbeRunPart[], bound: MemoryBound | undefined): { bytes: Buffer; output: ProbeOutput } {
|
|
38
|
+
if (parts.length === 0) throw new ProbeMergeError("no run to merge");
|
|
39
|
+
const outcomes = parts.map((p) => (outOfMemory(p.output, bound) ? PROBE_OOM : p.output.run.outcome));
|
|
40
|
+
const first = parts[0]!;
|
|
41
|
+
for (const p of parts.slice(1)) {
|
|
42
|
+
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) {
|
|
43
|
+
throw new ProbeMergeError(
|
|
44
|
+
`the runs of ${first.output.plugin.id} measured different things (${p.output.plugin.id}, ${p.output.binary.sha256.slice(0, 12)}, offer ${p.output.offer.id}): the plugin's files changed between them`,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const missingCell = (p: ProbeRunPart): boolean => p.invocation.cells.some((c) => !p.output.grid.some((g) => g.rate === c.rate && g.quantum === c.quantum));
|
|
49
|
+
if (parts.length === 1 && outcomes[0] === first.output.run.outcome && (outcomes[0] === PROBE_OK || !missingCell(first))) {
|
|
50
|
+
return { bytes: first.bytes, output: first.output };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const docs = parts.map((p) => JSON.parse(p.bytes.toString("utf8")) as Json);
|
|
54
|
+
const merged = structuredClone(docs[0]!);
|
|
55
|
+
const grid: Json[] = [];
|
|
56
|
+
const seen = new Set<string>();
|
|
57
|
+
const steps: unknown[] = [];
|
|
58
|
+
parts.forEach((p, i) => {
|
|
59
|
+
for (const point of (docs[i]!["grid"] as Json[] | undefined) ?? []) {
|
|
60
|
+
const key = cellKey({ rate: point["rate"] as number, quantum: point["quantum"] as number });
|
|
61
|
+
if (seen.has(key)) continue;
|
|
62
|
+
seen.add(key);
|
|
63
|
+
grid.push(point);
|
|
64
|
+
}
|
|
65
|
+
if (outcomes[i] !== PROBE_OK) {
|
|
66
|
+
for (const c of p.invocation.cells) {
|
|
67
|
+
if (seen.has(cellKey(c))) continue;
|
|
68
|
+
seen.add(cellKey(c));
|
|
69
|
+
grid.push({ rate: c.rate, quantum: c.quantum, outcome: outcomes[i], ...(outcomes[i] === PROBE_OOM && bound !== undefined ? { memoryLimit: { bytes: bound.bytes, mechanism: bound.mechanism } } : {}) });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
steps.push(...((docs[i]!["steps"] as unknown[] | undefined) ?? []));
|
|
73
|
+
});
|
|
74
|
+
const judged = outcomes.filter((o) => o !== PROBE_OOM);
|
|
75
|
+
const run = merged["run"] as Json;
|
|
76
|
+
merged["run"] = {
|
|
77
|
+
...run,
|
|
78
|
+
elapsedMs: parts.reduce((sum, p) => sum + p.output.run.elapsedMs, 0),
|
|
79
|
+
complete: parts.every((p, i) => p.output.run.complete && outcomes[i] === PROBE_OK),
|
|
80
|
+
outcome: judged.length === 0 ? PROBE_OOM : (judged.find((o) => o !== PROBE_OK) ?? PROBE_OK),
|
|
81
|
+
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
|
+
merged["steps"] = steps;
|
|
84
|
+
merged["grid"] = grid;
|
|
85
|
+
const bytes = Buffer.from(`${JSON.stringify(merged, null, 2)}\n`);
|
|
86
|
+
const read = readProbeOutput(bytes.toString("utf8"));
|
|
87
|
+
if (!read.ok) throw new ProbeMergeError(`the merged runs of ${first.output.plugin.id} do not read as a probe output (${read.code}): ${read.problem}`);
|
|
88
|
+
return { bytes, output: read.output };
|
|
89
|
+
}
|
package/src/probe-output.ts
CHANGED
|
@@ -95,6 +95,8 @@ export interface ProbeGridPoint {
|
|
|
95
95
|
readonly cost?: ProbeCost;
|
|
96
96
|
readonly rtSafety?: ProbeRtSafety;
|
|
97
97
|
readonly noteSweep?: ProbeNoteSweep;
|
|
98
|
+
/** A cell whose run outgrew the memory bound (`probe.oom`): the bound it ran under. */
|
|
99
|
+
readonly memoryLimit?: { readonly bytes: number; readonly mechanism: string };
|
|
98
100
|
}
|
|
99
101
|
|
|
100
102
|
export interface ProbeStep {
|
|
@@ -105,6 +107,9 @@ export interface ProbeStep {
|
|
|
105
107
|
/** The hosting code the step carries when it says why a figure is absent (`hosting.latency.unmeasured`). */
|
|
106
108
|
readonly hostingCode?: string;
|
|
107
109
|
readonly detail?: string;
|
|
110
|
+
/** How the child ended, when this step is a child's end: its exit status, or the signal that killed it. */
|
|
111
|
+
readonly exitStatus?: number;
|
|
112
|
+
readonly signal?: string;
|
|
108
113
|
}
|
|
109
114
|
|
|
110
115
|
export interface ProbeIdleState {
|
|
@@ -301,9 +306,15 @@ function readPoint(v: unknown, i: number): ProbeGridPoint {
|
|
|
301
306
|
...(o["cost"] === undefined ? {} : { cost: readCost(o["cost"], `${where} cost`) }),
|
|
302
307
|
...(o["rtSafety"] === undefined ? {} : { rtSafety: readRtSafety(o["rtSafety"], `${where} rtSafety`) }),
|
|
303
308
|
...(o["noteSweep"] === undefined ? {} : { noteSweep: readNoteSweep(o["noteSweep"], `${where} noteSweep`) }),
|
|
309
|
+
...(o["memoryLimit"] === undefined ? {} : { memoryLimit: readMemoryLimit(o["memoryLimit"], `${where} memoryLimit`) }),
|
|
304
310
|
};
|
|
305
311
|
}
|
|
306
312
|
|
|
313
|
+
function readMemoryLimit(v: unknown, where: string): { bytes: number; mechanism: string } {
|
|
314
|
+
const o = record(v, where);
|
|
315
|
+
return { bytes: posInt(o, "bytes", where), mechanism: str(o, "mechanism", where) };
|
|
316
|
+
}
|
|
317
|
+
|
|
307
318
|
function readStep(v: unknown, i: number): ProbeStep {
|
|
308
319
|
const where = `steps[${i}]`;
|
|
309
320
|
const o = record(v, where);
|
|
@@ -314,6 +325,8 @@ function readStep(v: unknown, i: number): ProbeStep {
|
|
|
314
325
|
...optional("quantum", o["quantum"] === undefined ? undefined : posInt(o, "quantum", where)),
|
|
315
326
|
...optional("hostingCode", nonEmptyString(o["hostingCode"])),
|
|
316
327
|
...optional("detail", nonEmptyString(o["detail"])),
|
|
328
|
+
...optional("exitStatus", typeof o["exitStatus"] === "number" && Number.isInteger(o["exitStatus"]) ? o["exitStatus"] : undefined),
|
|
329
|
+
...optional("signal", nonEmptyString(o["signal"])),
|
|
317
330
|
};
|
|
318
331
|
}
|
|
319
332
|
|