@openmixer/plugin-qualify 0.18.4 → 0.19.1
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 +19 -730
- package/bin/plugin-qualify-sync.mjs +4 -2
- package/bin/plugin-qualify.mjs +7 -0
- package/dist/clap-qualify.d.ts +1 -1
- package/dist/compare.d.ts +311 -0
- package/dist/compare.js +502 -0
- package/dist/compare.js.map +1 -0
- package/dist/distributed-records.d.ts +0 -2
- package/dist/distributed-records.js +0 -2
- package/dist/distributed-records.js.map +1 -1
- package/dist/host-admission.d.ts +2 -2
- package/dist/host-admission.js +7 -0
- package/dist/host-admission.js.map +1 -1
- package/dist/hosting-codes.generated.d.ts +1 -0
- package/dist/hosting-codes.generated.js +1 -0
- package/dist/hosting-codes.generated.js.map +1 -1
- package/dist/hosting-suitability.d.ts +3 -1
- package/dist/hosting-suitability.js +9 -0
- 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/jack-registry.d.ts +6 -0
- package/dist/jack-registry.js +52 -0
- package/dist/jack-registry.js.map +1 -0
- package/dist/lv2-loader.js +6 -5
- package/dist/lv2-loader.js.map +1 -1
- package/dist/measurement/format.d.ts +15 -0
- package/dist/measurement/format.js +19 -0
- package/dist/measurement/format.js.map +1 -1
- package/dist/measurement/sweep-jsonl.js +5 -1
- package/dist/measurement/sweep-jsonl.js.map +1 -1
- package/dist/measurement-grid.js +1 -1
- package/dist/pins-file.d.ts +22 -2
- package/dist/pins-file.js +118 -17
- package/dist/pins-file.js.map +1 -1
- package/dist/probe-judge.d.ts +5 -0
- package/dist/probe-judge.js.map +1 -1
- package/dist/probe-runner.d.ts +8 -0
- package/dist/probe-runner.js +8 -4
- package/dist/probe-runner.js.map +1 -1
- package/dist/qualification-record.d.ts +45 -0
- package/dist/qualification-record.js +36 -0
- package/dist/qualification-record.js.map +1 -1
- package/dist/qualify-adapter-pass.js +20 -5
- package/dist/qualify-adapter-pass.js.map +1 -1
- package/dist/qualify-declarations.d.ts +6 -0
- package/dist/qualify-declarations.js +8 -0
- package/dist/qualify-declarations.js.map +1 -1
- package/dist/qualify-isolated.d.ts +16 -3
- package/dist/qualify-isolated.js +31 -3
- package/dist/qualify-isolated.js.map +1 -1
- package/dist/qualify-pool.d.ts +129 -0
- package/dist/qualify-pool.js +290 -0
- package/dist/qualify-pool.js.map +1 -0
- package/dist/qualify-rejudge.js +7 -0
- package/dist/qualify-rejudge.js.map +1 -1
- package/dist/qualify-sync.d.ts +46 -1
- package/dist/qualify-sync.js +184 -20
- package/dist/qualify-sync.js.map +1 -1
- package/dist/records-package.d.ts +5 -0
- package/dist/records-package.js +25 -8
- package/dist/records-package.js.map +1 -1
- package/fixtures/clap-scan/fake.json +8 -2
- package/fixtures/probe-output/success-msi.json +2 -2
- package/package.json +3 -3
- package/src/clap-qualify.ts +1 -1
- package/src/compare.ts +699 -0
- package/src/distributed-records.ts +0 -3
- package/src/host-admission.ts +9 -2
- package/src/hosting-codes.generated.ts +1 -0
- package/src/hosting-suitability.ts +14 -0
- package/src/index.ts +1 -0
- package/src/jack-registry.ts +49 -0
- package/src/lv2-loader.ts +5 -4
- package/src/measurement/format.ts +32 -0
- package/src/measurement/sweep-jsonl.ts +5 -1
- package/src/measurement-grid.ts +1 -1
- package/src/pins-file.ts +109 -16
- package/src/probe-judge.ts +2 -0
- package/src/probe-runner.ts +18 -4
- package/src/qualification-record.ts +69 -0
- package/src/qualify-adapter-pass.ts +18 -5
- package/src/qualify-declarations.ts +8 -0
- package/src/qualify-isolated.ts +31 -5
- package/src/qualify-pool.ts +318 -0
- package/src/qualify-rejudge.ts +8 -0
- package/src/qualify-sync.ts +187 -19
- package/src/records-package.ts +25 -8
- package/tools/benchmark.py +9 -3
- package/tools/compare-host.mjs +432 -0
- package/tools/compare-run.mjs +355 -0
- package/tools/descriptors.py +27 -0
- package/tools/isolated-step.mjs +3 -1
- package/tools/lilv_load.py +25 -0
- package/tools/lv2_resolve.py +3 -1
- package/tools/pool-step.mjs +186 -0
- package/tools/scan.py +16 -20
- package/tools/audit-overlap.sh +0 -250
- package/tools/commit-message-check.sh +0 -128
- package/tools/commit-message-patterns.json +0 -41
- package/tools/private-refs-check.sh +0 -90
|
@@ -29,9 +29,6 @@ export const DISTRIBUTED_DIR_SYSTEM = "/usr/share/omx/qualification";
|
|
|
29
29
|
export const DISTRIBUTED_PROVENANCE_FILE = "provenance.json";
|
|
30
30
|
export const DISTRIBUTED_PROVENANCE_SCHEMA = "plugin-qualify.distributed-provenance/1";
|
|
31
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
32
|
/** Where the records of a set come from. The keys below are always there; the rest is what the builder was given. */
|
|
36
33
|
export interface DistributedProvenance {
|
|
37
34
|
readonly schema: typeof DISTRIBUTED_PROVENANCE_SCHEMA;
|
package/src/host-admission.ts
CHANGED
|
@@ -45,10 +45,10 @@ export function runDecides(profile: HostProfile, format: string, admission: Host
|
|
|
45
45
|
/** What running the plugin under one host came to. */
|
|
46
46
|
export interface HostAdmission {
|
|
47
47
|
readonly host: AdmittedHost | typeof ADAPTER_HOST;
|
|
48
|
-
/** False when the host could not be run (not installed): there is no evidence either way. */
|
|
48
|
+
/** False when the host could not be run (not installed, no JACK client): there is no evidence either way. */
|
|
49
49
|
readonly measured: boolean;
|
|
50
50
|
readonly admitted: boolean;
|
|
51
|
-
/** Why not: `<host>.resp-<n>`, `<host>.signal-<SIG>`, `mod-host.died-on-add`, `jalv.unknown-port-type`, `<host>.not-installed`, … */
|
|
51
|
+
/** Why not: `<host>.resp-<n>`, `<host>.signal-<SIG>`, `mod-host.died-on-add`, `jalv.unknown-port-type`, `<host>.not-installed`, `<host>.no-jack-client`, … */
|
|
52
52
|
readonly code?: string;
|
|
53
53
|
readonly params: Readonly<Record<string, string | number>>;
|
|
54
54
|
}
|
|
@@ -103,6 +103,12 @@ export function admissionRating(admission: HostAdmission): "unsuitable" | "unkno
|
|
|
103
103
|
return !admission.measured || UNPROVEN_SOUND.includes(admission.code ?? "") ? "unknown" : "unsuitable";
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/**
|
|
107
|
+
* ERR_JACK_CLIENT_CREATION and ERR_JACK_CLIENT_ACTIVATION (mod-host's host-errors.h): the add could not get a JACK client,
|
|
108
|
+
* which says nothing of the plugin. omx-clap-host opens JACK at the first add since 0.26.1, so a host with no server answers these.
|
|
109
|
+
*/
|
|
110
|
+
const JACK_CLIENT_RESPS: readonly number[] = [-201, -202];
|
|
111
|
+
|
|
106
112
|
/** The verdict of a run of a host that speaks mod-host's protocol: the first step that did not answer `resp 0` decides. */
|
|
107
113
|
export function classifyProtocolRun(host: ProtocolHost, run: ModHostRun): HostAdmission {
|
|
108
114
|
if (!run.installed) return notInstalled(host);
|
|
@@ -114,6 +120,7 @@ export function classifyProtocolRun(host: ProtocolHost, run: ModHostRun): HostAd
|
|
|
114
120
|
return s.cmd === "add" ? refused(host, "died-on-add", { how: s.died }) : refused(host, "died", { step: s.cmd, how: s.died });
|
|
115
121
|
}
|
|
116
122
|
if (s.resp === undefined) return refused(host, "no-answer", { step: s.cmd });
|
|
123
|
+
if (s.cmd === "add" && s.resp !== undefined && JACK_CLIENT_RESPS.includes(s.resp)) return { host, measured: false, admitted: false, code: `${host}.no-jack-client`, params: { resp: s.resp } };
|
|
117
124
|
// the host answers an error as a negative number (`resp -102`); the code carries its magnitude
|
|
118
125
|
if (s.resp < 0) return refused(host, `resp-${-s.resp}`, { step: s.cmd, resp: s.resp });
|
|
119
126
|
}
|
|
@@ -62,6 +62,7 @@ export const HOSTING_CODE = Object.freeze({
|
|
|
62
62
|
"stabilityBelowFloor": "hosting.stability.cycles-below-floor",
|
|
63
63
|
"stabilityCrashUnattributed": "hosting.stability.crash-unattributed",
|
|
64
64
|
"stabilityCrashedAttributed": "hosting.stability.crashed-attributed-plugin",
|
|
65
|
+
"stabilityCalleeSavedClobbered": "hosting.stability.callee-saved-clobbered",
|
|
65
66
|
"stabilityCrashedLive": "hosting.stability.crashed-live-on-this-rig",
|
|
66
67
|
"stabilityLinksGuiToolkit": "hosting.stability.links-gui-toolkit",
|
|
67
68
|
"stabilitySoakFailedAtRate": "hosting.stability.soak-failed-at-rate",
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
type SuitabilityRating,
|
|
24
24
|
} from "./rating.js";
|
|
25
25
|
import type {
|
|
26
|
+
AbiReading,
|
|
26
27
|
FeaturesMeasurement,
|
|
27
28
|
LifecycleReading,
|
|
28
29
|
RtSafetyMeasurement,
|
|
@@ -299,6 +300,8 @@ export function attributeCrash(
|
|
|
299
300
|
*/
|
|
300
301
|
export interface PluginStabilityMeasurement {
|
|
301
302
|
readonly lifecycle?: LifecycleReading;
|
|
303
|
+
/** Whether a plugin call returned with a callee-saved register changed (the measurer's guard put it back). */
|
|
304
|
+
readonly abi?: AbiReading;
|
|
302
305
|
readonly threads?: ThreadReading;
|
|
303
306
|
readonly crashes?: readonly ObservedCrash[];
|
|
304
307
|
/** A continuous render per rate with the controls swept — judged at EVERY rate it ran. */
|
|
@@ -587,6 +590,17 @@ export function stabilityReasons(
|
|
|
587
590
|
reason("stability", "objective", "unsuitable", HOSTING_CODE.stabilityCrashedLive, {}),
|
|
588
591
|
);
|
|
589
592
|
}
|
|
593
|
+
// a plugin call that returned with a callee-saved register changed: the host's guard puts it back, but not the other
|
|
594
|
+
// members' state in the same process, so the plugin is a hosting mode, never a refusal: a worker of its own
|
|
595
|
+
if (m.abi?.clobbered === true) {
|
|
596
|
+
out.push(
|
|
597
|
+
reason("stability", "objective", "conditional", HOSTING_CODE.stabilityCalleeSavedClobbered, {
|
|
598
|
+
registers: (m.abi.registers ?? []).join(","),
|
|
599
|
+
call: m.abi.call ?? "",
|
|
600
|
+
block: m.abi.block ?? 0,
|
|
601
|
+
}),
|
|
602
|
+
);
|
|
603
|
+
}
|
|
590
604
|
const attributions = (m.crashes ?? []).map((c) => attributeCrash(c, CRASH_SIGNATURES, pluginBinary));
|
|
591
605
|
if (attributions.includes("plugin")) {
|
|
592
606
|
out.push(reason("stability", "objective", "unsuitable", HOSTING_CODE.stabilityCrashedAttributed));
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
// Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
|
|
3
|
+
/**
|
|
4
|
+
* JACK's table of its servers, /dev/shm/jack-shm-registry: a header, then eight rows of a pid and a name
|
|
5
|
+
* (`jack-<uid>:<server>:`). A server that is killed never clears its row, and once the eight are taken no server starts.
|
|
6
|
+
* The adapter pass starts and stops servers of its own, `omx-qualify-<pid>-<quantum>`; a row of one of those whose
|
|
7
|
+
* process is gone is freed (pid 0, name empty) before the next start, as jackd frees its own row when it ends well.
|
|
8
|
+
* Rows of any other server are never touched, and a file of another layout is left as it is.
|
|
9
|
+
*/
|
|
10
|
+
import { closeSync, existsSync, openSync, readSync, writeSync } from "node:fs";
|
|
11
|
+
|
|
12
|
+
export const JACK_REGISTRY = "/dev/shm/jack-shm-registry";
|
|
13
|
+
/** the header jack2 writes before the server rows, and the size of a row: a pid, then the name */
|
|
14
|
+
export const JACK_REGISTRY_HEADER = 24;
|
|
15
|
+
export const JACK_REGISTRY_ENTRY = 264;
|
|
16
|
+
const ROWS = 8;
|
|
17
|
+
const MAGIC = "KCAJ";
|
|
18
|
+
const OWN = ":omx-qualify-";
|
|
19
|
+
|
|
20
|
+
const alive = (pid: number): boolean => existsSync(`/proc/${String(pid)}`);
|
|
21
|
+
|
|
22
|
+
/** Frees the rows of the pass's own servers that are gone; the names freed, in the table's order. */
|
|
23
|
+
export function freeDeadJackSlots(path: string = process.env["OMX_JACK_REGISTRY"] ?? JACK_REGISTRY): string[] {
|
|
24
|
+
const freed: string[] = [];
|
|
25
|
+
let fd: number;
|
|
26
|
+
try {
|
|
27
|
+
fd = openSync(path, "r+");
|
|
28
|
+
} catch {
|
|
29
|
+
return freed;
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
const head = Buffer.alloc(JACK_REGISTRY_HEADER + ROWS * JACK_REGISTRY_ENTRY);
|
|
33
|
+
if (readSync(fd, head, 0, head.length, 0) !== head.length) return freed;
|
|
34
|
+
// the layout this code knows, or nothing is touched
|
|
35
|
+
if (head.toString("latin1", 0, 4) !== MAGIC || head.readUInt32LE(16) !== JACK_REGISTRY_HEADER + ROWS * JACK_REGISTRY_ENTRY) return freed;
|
|
36
|
+
for (let k = 0; k < ROWS; k++) {
|
|
37
|
+
const at = JACK_REGISTRY_HEADER + k * JACK_REGISTRY_ENTRY;
|
|
38
|
+
const pid = head.readInt32LE(at);
|
|
39
|
+
const name = head.toString("latin1", at + 4, at + JACK_REGISTRY_ENTRY).split("\0")[0] ?? "";
|
|
40
|
+
if (pid <= 0 || !name.includes(OWN) || alive(pid)) continue;
|
|
41
|
+
writeSync(fd, Buffer.alloc(4), 0, 4, at);
|
|
42
|
+
writeSync(fd, Buffer.alloc(1), 0, 1, at + 4);
|
|
43
|
+
freed.push(name);
|
|
44
|
+
}
|
|
45
|
+
} finally {
|
|
46
|
+
closeSync(fd);
|
|
47
|
+
}
|
|
48
|
+
return freed;
|
|
49
|
+
}
|
package/src/lv2-loader.ts
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* The LV2 implementation of the one discovery seam (`loader.ts`, spec §7). A target that names an
|
|
5
5
|
* existing directory is a bundle: every subject its `manifest.ttl` declares `a lv2:Plugin` is a
|
|
6
|
-
* plugin, with its `lv2:binary` basename. Anything
|
|
7
|
-
* plugin. A path that does not exist is not this loader's — the CLI refuses it by name.
|
|
6
|
+
* plugin, with its `lv2:binary` basename. Anything with a scheme (`scheme:rest`, other than `file:`) is one
|
|
7
|
+
* plugin, looked up by URI; a bare or `file:` argument is a path. A path that does not exist is not this loader's — the CLI refuses it by name.
|
|
8
8
|
*
|
|
9
9
|
* The manifest is read as plugin-hostd reads it to pin a plugin: statements of IRIs and prefixed names, with blank
|
|
10
10
|
* nodes and collections stepped over, not a whole Turtle parser. A plugin is a subject typed `lv2:Plugin`; its files
|
|
@@ -169,8 +169,9 @@ export const lv2Loader: PluginLoader = {
|
|
|
169
169
|
...(p.binary === undefined ? {} : { binary: p.binary }),
|
|
170
170
|
}));
|
|
171
171
|
}
|
|
172
|
-
|
|
173
|
-
|
|
172
|
+
// A scheme (http, https, urn, ...) makes the argument a URI; a bare or `file:` argument is a path, and one that is not there is not this loader's.
|
|
173
|
+
if (!/^file:/i.test(target) && URI_SHAPE.test(target) && !/^[A-Za-z]:[\\/]/.test(target)) return [{ format: "lv2", uri: target }];
|
|
174
|
+
return undefined;
|
|
174
175
|
},
|
|
175
176
|
};
|
|
176
177
|
|
|
@@ -425,8 +425,22 @@ export interface SoakReading {
|
|
|
425
425
|
}
|
|
426
426
|
|
|
427
427
|
/** Everything one run learned about one plugin SURVIVING. Readable when it has a lifecycle. */
|
|
428
|
+
/**
|
|
429
|
+
* The plugin calls the measurer made through omx-clap-core's guard: whether one returned with a callee-saved register
|
|
430
|
+
* changed, and for the first, the registers, the plugin function and the run block; calls counts all that did. The
|
|
431
|
+
* guard put them back, so the measurement is whole; a plugin that clobbered runs only in a worker of its own.
|
|
432
|
+
*/
|
|
433
|
+
export interface AbiReading {
|
|
434
|
+
readonly clobbered: boolean;
|
|
435
|
+
readonly registers?: readonly string[];
|
|
436
|
+
readonly call?: string;
|
|
437
|
+
readonly block?: number;
|
|
438
|
+
readonly calls?: number;
|
|
439
|
+
}
|
|
440
|
+
|
|
428
441
|
export interface StabilityMeasurement {
|
|
429
442
|
readonly lifecycle?: LifecycleReading;
|
|
443
|
+
readonly abi?: AbiReading;
|
|
430
444
|
readonly threads?: ThreadReading;
|
|
431
445
|
readonly crashes?: readonly CrashReading[];
|
|
432
446
|
readonly soak?: SoakReading;
|
|
@@ -1078,10 +1092,28 @@ function readSoak(value: unknown): SoakReading | undefined {
|
|
|
1078
1092
|
return { sweptParams, perRate: readRateMap(value.perRate, readSoakAtRate) };
|
|
1079
1093
|
}
|
|
1080
1094
|
|
|
1095
|
+
/** The measurer's abi field: a boolean clobbered, and with it the first clobber's registers, call and block. */
|
|
1096
|
+
export function readAbi(value: unknown): AbiReading | undefined {
|
|
1097
|
+
if (!isRecord(value)) return undefined;
|
|
1098
|
+
const clobbered = booleanValue(value.clobbered);
|
|
1099
|
+
if (clobbered === undefined) return undefined;
|
|
1100
|
+
const registers = Array.isArray(value.registers)
|
|
1101
|
+
? value.registers.filter((r): r is string => typeof r === "string" && r.length > 0)
|
|
1102
|
+
: undefined;
|
|
1103
|
+
return {
|
|
1104
|
+
clobbered,
|
|
1105
|
+
...optional("registers", clobbered ? registers : undefined),
|
|
1106
|
+
...optional("call", clobbered ? nonEmptyString(value.call) : undefined),
|
|
1107
|
+
...optional("block", clobbered ? finiteNumber(value.block) : undefined),
|
|
1108
|
+
...optional("calls", clobbered ? finiteNumber(value.calls) : undefined),
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1081
1112
|
function readStability(value: unknown): StabilityMeasurement | undefined {
|
|
1082
1113
|
if (!isRecord(value)) return undefined;
|
|
1083
1114
|
return {
|
|
1084
1115
|
...optional("lifecycle", readLifecycle(value.lifecycle)),
|
|
1116
|
+
...optional("abi", readAbi(value.abi)),
|
|
1085
1117
|
...optional("threads", readThreads(value.threads)),
|
|
1086
1118
|
...optional("crashes", mapArray(value.crashes, readCrash)),
|
|
1087
1119
|
...optional("soak", readSoak(value.soak)),
|
|
@@ -29,7 +29,7 @@ import type {
|
|
|
29
29
|
SoakReading,
|
|
30
30
|
StabilityMeasurement,
|
|
31
31
|
} from "./format.js";
|
|
32
|
-
import { readIntegrity, readLifecycle, readNoteSweep, readSoakAtRate, readThreads } from "./format.js";
|
|
32
|
+
import { readAbi, readIntegrity, readLifecycle, readNoteSweep, readSoakAtRate, readThreads } from "./format.js";
|
|
33
33
|
import { booleanValue, finiteNumber, isRecord, mapArray, nonEmptyString, optional, positiveNumber } from "./guards.js";
|
|
34
34
|
|
|
35
35
|
/** The sweep's three passes, each a list of parsed JSONL lines. Only `measurements` is required. */
|
|
@@ -82,8 +82,12 @@ function stabilityOf(
|
|
|
82
82
|
(c): c is CrashReading => c !== undefined,
|
|
83
83
|
);
|
|
84
84
|
const soak = soakRow === undefined ? undefined : soakOf(soakRow);
|
|
85
|
+
// either pass's clobber is the plugin's: the deep pass runs the same plugin longer
|
|
86
|
+
const abis = [readAbi(quick.abi), deep === undefined ? undefined : readAbi(deep.abi)];
|
|
87
|
+
const abi = abis.find((a) => a?.clobbered === true) ?? abis.find((a) => a !== undefined);
|
|
85
88
|
return {
|
|
86
89
|
lifecycle,
|
|
90
|
+
...optional("abi", abi),
|
|
87
91
|
...optional("threads", threads),
|
|
88
92
|
crashes,
|
|
89
93
|
...optional("soak", soak),
|
package/src/measurement-grid.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* The measurement grid (#193): the cells, each one rate and one quantum, a plugin is measured at, and the runs of the
|
|
5
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;
|
|
6
|
+
* judges; docs/overview.md "The measurement grid" states the three ways of choosing it and the order the cells are taken in.
|
|
7
7
|
*
|
|
8
8
|
* Pure: no file, no process. `probe-runner.ts` runs what `runnerInvocations` plans.
|
|
9
9
|
*/
|
package/src/pins-file.ts
CHANGED
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
* pin store's import): one line per qualified plugin, the SAME line as hostd's `pin_set` verb, with the
|
|
6
6
|
* qualification verdict as a last word.
|
|
7
7
|
*
|
|
8
|
-
* pin_set <uri> <path>=<sha256>[,<path>=<sha256>...] <scheme>:<sha256> [verdict=<word>]
|
|
8
|
+
* pin_set <uri> <path>=<sha256>[,<path>=<sha256>...] <scheme>:<sha256> [verdict=<word>] [pool=<word>]
|
|
9
|
+
*
|
|
10
|
+
* A word is the protocol's (plugin-hostd-protocol's `word`): bare, or in double quotes when it holds a space, with `\"`
|
|
11
|
+
* for a quote inside. A CLAP plugin under a directory with a space in its name has a uri with a space in it: one quoted word.
|
|
9
12
|
*
|
|
10
13
|
* The digests and the layout are the prober's, raw (`IntegrityMeasurement`); nothing here computes a hash. A plugin
|
|
11
14
|
* whose run took no whole pin has no line: a pins file never holds a partial pin. The grammar is plugin-hostd's
|
|
@@ -26,6 +29,13 @@ export const PIN_VERB = "pin_set";
|
|
|
26
29
|
/** The key of the verdict word, hostd's `PHD_PIN_VERDICT_KEY`. */
|
|
27
30
|
export const PIN_VERDICT_KEY = "verdict=";
|
|
28
31
|
|
|
32
|
+
/** The key of the pool word, hostd's `PHD_PIN_POOL_KEY`. */
|
|
33
|
+
export const PIN_POOL_KEY = "pool=";
|
|
34
|
+
|
|
35
|
+
/** The pool words hostd knows (`PHD_PIN_POOLS`): `cores` starts the plugin's worker with the CLAP thread pool on, `off` says it was measured and gains nothing. */
|
|
36
|
+
export const PIN_POOLS = ["cores", "off"] as const;
|
|
37
|
+
export type PinPool = (typeof PIN_POOLS)[number];
|
|
38
|
+
|
|
29
39
|
/** The verdict words hostd knows (`PHD_PIN_VERDICTS`). */
|
|
30
40
|
export const PIN_VERDICTS = ["in-process", "isolated", "refused"] as const;
|
|
31
41
|
export type PinVerdict = (typeof PIN_VERDICTS)[number];
|
|
@@ -54,19 +64,84 @@ export interface PinEntry {
|
|
|
54
64
|
readonly uri: string;
|
|
55
65
|
readonly integrity: IntegrityMeasurement;
|
|
56
66
|
readonly verdict?: PinVerdict;
|
|
67
|
+
/** What the pool step measured: the pool is the plugin's qualification to give, never assumed. A refused pin carries none. */
|
|
68
|
+
readonly pool?: PinPool;
|
|
57
69
|
}
|
|
58
70
|
|
|
59
|
-
/**
|
|
60
|
-
export function
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
71
|
+
/** A word of the protocol, `word` in its grammar: bare, or quoted when it holds a space or a double quote. A control character cannot be a word, nor can a quoted word end in a backslash (it would read as an escaped quote). */
|
|
72
|
+
export function pinWord(value: string): string {
|
|
73
|
+
if (value === "") throw new Error("an empty word cannot be a word of a pin line");
|
|
74
|
+
// eslint-disable-next-line no-control-regex
|
|
75
|
+
if (/[\x00-\x1f\x7f]/.test(value)) throw new Error(`${JSON.stringify(value)} has a control character in it and cannot be a word of a pin line`);
|
|
76
|
+
if (!/[ "]/.test(value)) return value;
|
|
77
|
+
if (value.endsWith("\\")) throw new Error(`${JSON.stringify(value)} ends in a backslash and cannot be a quoted word of a pin line`);
|
|
78
|
+
return `"${value.replaceAll('"', '\\"')}"`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The words of a line, the protocol's: a quoted word with its quotes taken off and its `\"` read as a quote. */
|
|
82
|
+
export function pinWords(line: string): string[] {
|
|
83
|
+
const words: string[] = [];
|
|
84
|
+
let i = 0;
|
|
85
|
+
while (i < line.length) {
|
|
86
|
+
if (line[i] === " " || line[i] === "\t" || line[i] === "\r") {
|
|
87
|
+
i++;
|
|
88
|
+
} else if (line[i] === '"') {
|
|
89
|
+
let word = "";
|
|
90
|
+
for (i++; ; i++) {
|
|
91
|
+
if (i >= line.length) throw new Error("a quote is not closed");
|
|
92
|
+
if (line[i] === "\\" && line[i + 1] === '"') {
|
|
93
|
+
word += '"';
|
|
94
|
+
i++;
|
|
95
|
+
} else if (line[i] === '"') {
|
|
96
|
+
i++;
|
|
97
|
+
break;
|
|
98
|
+
} else {
|
|
99
|
+
word += line[i];
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (i < line.length && line[i] !== " " && line[i] !== "\t" && line[i] !== "\r") throw new Error("a quoted word is followed by more of the word");
|
|
103
|
+
words.push(word);
|
|
104
|
+
} else {
|
|
105
|
+
const start = i;
|
|
106
|
+
while (i < line.length && line[i] !== " " && line[i] !== "\t" && line[i] !== "\r") {
|
|
107
|
+
if (line[i] === '"') throw new Error("a quote inside a bare word");
|
|
108
|
+
i++;
|
|
109
|
+
}
|
|
110
|
+
words.push(line.slice(start, i));
|
|
111
|
+
}
|
|
64
112
|
}
|
|
113
|
+
return words;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The uri of a pin line (or of a `pin_clear` line), with the verb or without: its first word after the verb. */
|
|
117
|
+
export function pinLineUri(line: string): string | undefined {
|
|
118
|
+
const words = pinWords(line);
|
|
119
|
+
if (words[0] === PIN_VERB || words[0] === "pin_clear") words.shift();
|
|
120
|
+
return words[0];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The line that takes a pin away: the uri is one word. */
|
|
124
|
+
export function pinClearLine(uri: string): string {
|
|
125
|
+
return `pin_clear ${pinWord(uri)}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The line of one pin, or `undefined` when the integrity block is not a whole pin. The uri is one word, quoted when it
|
|
130
|
+
* holds a space. A file path inside the bundle cannot hold whitespace, a comma or an equals sign (the protocol's PATH
|
|
131
|
+
* limit: they are the separators of the words of a pin), so a plugin with such a file has no pin, refused by name.
|
|
132
|
+
*/
|
|
133
|
+
export function pinLine(entry: PinEntry): string | undefined {
|
|
65
134
|
const { files, layout } = entry.integrity;
|
|
135
|
+
const uri = pinWord(entry.uri);
|
|
136
|
+
for (const f of files ?? []) {
|
|
137
|
+
const bad = /[,="\s]/.exec(f.path);
|
|
138
|
+
if (bad !== null) throw new Error(`${entry.uri}: the file ${JSON.stringify(f.path)} holds ${JSON.stringify(bad[0])}, which a pinned path cannot (a separator of the words of a pin, or a quote)`);
|
|
139
|
+
}
|
|
66
140
|
if (files === undefined || files.length === 0 || layout === undefined) return undefined;
|
|
67
141
|
const list = files.map((f) => `${f.path}=${f.sha256}`).join(",");
|
|
68
142
|
const verdict = entry.verdict === undefined ? "" : ` ${PIN_VERDICT_KEY}${entry.verdict}`;
|
|
69
|
-
|
|
143
|
+
const pool = entry.pool === undefined || entry.verdict === "refused" ? "" : ` ${PIN_POOL_KEY}${entry.pool}`;
|
|
144
|
+
return `${PIN_VERB} ${uri} ${list} ${layout.scheme}:${layout.sha256}${verdict}${pool}`;
|
|
70
145
|
}
|
|
71
146
|
|
|
72
147
|
/** hostd's uri of a CLAP plugin the prober loaded: its file as it was handed, made absolute, and its id. */
|
|
@@ -82,7 +157,7 @@ export interface PinsFile {
|
|
|
82
157
|
readonly written: readonly string[];
|
|
83
158
|
/** The uris of plugins that took no whole pin: they have no line. */
|
|
84
159
|
readonly skipped: readonly string[];
|
|
85
|
-
/** The uris refused because a word of their line
|
|
160
|
+
/** The uris refused because a word of their line cannot be a word (a control character), each with the reason: they have no line. */
|
|
86
161
|
readonly refused: readonly { readonly uri: string; readonly problem: string }[];
|
|
87
162
|
}
|
|
88
163
|
|
|
@@ -175,6 +250,7 @@ export interface ParsedPin {
|
|
|
175
250
|
readonly files: readonly { readonly path: string; readonly sha256: string }[];
|
|
176
251
|
readonly layout: { readonly scheme: string; readonly sha256: string };
|
|
177
252
|
readonly verdict?: PinVerdict;
|
|
253
|
+
readonly pool?: PinPool;
|
|
178
254
|
}
|
|
179
255
|
|
|
180
256
|
const HEX = /^[0-9a-f]{64}$/;
|
|
@@ -185,11 +261,11 @@ const HEX = /^[0-9a-f]{64}$/;
|
|
|
185
261
|
* 64 lowercase hex digits, a layout without a scheme, a verdict outside the vocabulary, a path named twice).
|
|
186
262
|
*/
|
|
187
263
|
export function parsePinLine(line: string): ParsedPin | undefined {
|
|
188
|
-
const words = line
|
|
264
|
+
const words = pinWords(line);
|
|
189
265
|
if (words.length === 0 || words[0]!.startsWith("#")) return undefined;
|
|
190
266
|
if (words[0] === PIN_VERB) words.shift();
|
|
191
|
-
if (words.length
|
|
192
|
-
const [uri, list, layout,
|
|
267
|
+
if (words.length < 3 || words.length > 5) throw new Error(`a pin line has 3 to 5 words, not ${words.length}`);
|
|
268
|
+
const [uri, list, layout, ...optional] = words as [string, string, string, ...string[]];
|
|
193
269
|
const files = list.split(",").map((item) => {
|
|
194
270
|
const eq = item.lastIndexOf("=");
|
|
195
271
|
if (eq < 0) throw new Error(`${item}: no =`);
|
|
@@ -205,10 +281,27 @@ export function parsePinLine(line: string): ParsedPin | undefined {
|
|
|
205
281
|
const colon = layout.lastIndexOf(":");
|
|
206
282
|
if (colon <= 0 || !HEX.test(layout.slice(colon + 1))) throw new Error(`${layout}: not a layout pin`);
|
|
207
283
|
let verdict: PinVerdict | undefined;
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
if (
|
|
211
|
-
|
|
284
|
+
let pool: PinPool | undefined;
|
|
285
|
+
for (const word of optional) {
|
|
286
|
+
if (word.startsWith(PIN_VERDICT_KEY)) {
|
|
287
|
+
if (verdict !== undefined) throw new Error(`${word}: a second verdict`);
|
|
288
|
+
const value = word.slice(PIN_VERDICT_KEY.length);
|
|
289
|
+
if (!(PIN_VERDICTS as readonly string[]).includes(value)) throw new Error(`${word}: not a verdict`);
|
|
290
|
+
verdict = value as PinVerdict;
|
|
291
|
+
} else if (word.startsWith(PIN_POOL_KEY)) {
|
|
292
|
+
if (pool !== undefined) throw new Error(`${word}: a second pool word`);
|
|
293
|
+
const value = word.slice(PIN_POOL_KEY.length);
|
|
294
|
+
if (!(PIN_POOLS as readonly string[]).includes(value)) throw new Error(`${word}: not a pool word`);
|
|
295
|
+
pool = value as PinPool;
|
|
296
|
+
} else {
|
|
297
|
+
throw new Error(`${word}: not a word of a pin`);
|
|
298
|
+
}
|
|
212
299
|
}
|
|
213
|
-
return {
|
|
300
|
+
return {
|
|
301
|
+
uri,
|
|
302
|
+
files,
|
|
303
|
+
layout: { scheme: layout.slice(0, colon), sha256: layout.slice(colon + 1) },
|
|
304
|
+
...(verdict === undefined ? {} : { verdict }),
|
|
305
|
+
...(pool === undefined ? {} : { pool }),
|
|
306
|
+
};
|
|
214
307
|
}
|
package/src/probe-judge.ts
CHANGED
|
@@ -197,6 +197,8 @@ export interface HostJudgment {
|
|
|
197
197
|
|
|
198
198
|
export interface Judgment {
|
|
199
199
|
readonly output: ProbeOutput;
|
|
200
|
+
/** How loaded the machine was when the probe started (probe-runner's ProbeLoad); absent when not taken. */
|
|
201
|
+
readonly load?: { readonly loadavg1: number; readonly cores: number };
|
|
200
202
|
/** An LV2 plugin's run under plugin-hostd's LV2 adapter, beside the verdicts (plugin-qualify#223). */
|
|
201
203
|
readonly adapter?: HostAdmission;
|
|
202
204
|
readonly hosts: readonly HostJudgment[];
|
package/src/probe-runner.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { spawnSync } from "node:child_process";
|
|
15
15
|
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
16
|
-
import { tmpdir } from "node:os";
|
|
16
|
+
import { availableParallelism, loadavg, tmpdir } from "node:os";
|
|
17
17
|
import { join } from "node:path";
|
|
18
18
|
|
|
19
19
|
import { ClapQualifyError, clapQualifyTools, proberOffer, type RtSafetyDocument } from "./clap-qualify.js";
|
|
@@ -155,11 +155,22 @@ export function lv2RunnerArgs(
|
|
|
155
155
|
}
|
|
156
156
|
|
|
157
157
|
/** One probe output and the bytes it was read from (the bytes are what gets copied beside the record). */
|
|
158
|
+
/** How loaded the machine was when a probe started: the one-minute load average and the cores, so that a crash under a
|
|
159
|
+
* full machine can be told from one on an idle one. */
|
|
160
|
+
export interface ProbeLoad {
|
|
161
|
+
readonly loadavg1: number;
|
|
162
|
+
readonly cores: number;
|
|
163
|
+
}
|
|
164
|
+
|
|
158
165
|
export interface ProbedTarget {
|
|
159
166
|
readonly bytes: Buffer;
|
|
160
167
|
readonly output: ProbeOutput;
|
|
168
|
+
/** The most loaded the machine was when any of the target's runs started. */
|
|
169
|
+
readonly load?: ProbeLoad;
|
|
161
170
|
}
|
|
162
171
|
|
|
172
|
+
const loadNow = (): ProbeLoad => ({ loadavg1: Math.round(loadavg()[0]! * 100) / 100, cores: availableParallelism() });
|
|
173
|
+
|
|
163
174
|
function runOne(target: PluginTarget, options: ProbeTargetOptions, invocation: RunnerInvocation): ProbedTarget {
|
|
164
175
|
const env = options.env ?? process.env;
|
|
165
176
|
const tool = probeRunnerTool(env);
|
|
@@ -193,6 +204,7 @@ function runOne(target: PluginTarget, options: ProbeTargetOptions, invocation: R
|
|
|
193
204
|
}
|
|
194
205
|
// under a bound, the interposer ends the child the moment an allocation is refused, with the status that names it
|
|
195
206
|
if (options.memory !== undefined) runEnv = { ...runEnv, [MEMORY_BOUND_EXIT_ENV]: "1" };
|
|
207
|
+
const load = loadNow();
|
|
196
208
|
const r = (options.run ?? spawnRunner)(tool, args, runEnv);
|
|
197
209
|
if (r.error !== undefined) {
|
|
198
210
|
throw new ProbeRunnerError(`cannot run the probe runner "${tool}" (${r.error.message}): install plugin-hostd or set ${PROBE_RUNNER_ENV} to plugin-hostd-probe`);
|
|
@@ -214,7 +226,7 @@ function runOne(target: PluginTarget, options: ProbeTargetOptions, invocation: R
|
|
|
214
226
|
if (read.output.plugin.id !== expectedId) {
|
|
215
227
|
throw new ProbeRunnerError(`${tool} measured ${read.output.plugin.id} when asked for ${expectedId}: not judged as the plugin asked for`);
|
|
216
228
|
}
|
|
217
|
-
return { bytes, output: read.output };
|
|
229
|
+
return { bytes, output: read.output, load };
|
|
218
230
|
} finally {
|
|
219
231
|
rmSync(work, { recursive: true, force: true });
|
|
220
232
|
}
|
|
@@ -231,8 +243,10 @@ export function targetCells(options: Pick<ProbeTargetOptions, "rates" | "cells">
|
|
|
231
243
|
*/
|
|
232
244
|
export function probeTarget(target: PluginTarget, options: ProbeTargetOptions): ProbedTarget {
|
|
233
245
|
const parts = runnerInvocations(target.format, targetCells(options)).map((invocation) => ({ ...runOne(target, options, invocation), invocation }));
|
|
246
|
+
const loads = parts.flatMap((p) => (p.load === undefined ? [] : [p.load]));
|
|
247
|
+
const load = loads.length === 0 ? undefined : loads.reduce((a, b) => (b.loadavg1 > a.loadavg1 ? b : a));
|
|
234
248
|
try {
|
|
235
|
-
return mergeProbeRuns(parts, options.memory);
|
|
249
|
+
return { ...mergeProbeRuns(parts, options.memory), ...(load === undefined ? {} : { load }) };
|
|
236
250
|
} catch (e) {
|
|
237
251
|
if (e instanceof ProbeMergeError) throw new ProbeRunnerError(e.message);
|
|
238
252
|
throw e;
|
|
@@ -321,7 +335,7 @@ export function qualifyTargets(
|
|
|
321
335
|
for (const { offer, profiles: forOffer } of byOffer.values()) {
|
|
322
336
|
const probed = probeTarget(target, { ...options, ...measured, offer });
|
|
323
337
|
const plain = judgeProbeOutput(probed.output, forOffer, { admissions });
|
|
324
|
-
const judgment = adapter === undefined ?
|
|
338
|
+
const judgment = { ...plain, ...(adapter === undefined ? {} : { adapter }), ...(probed.load === undefined ? {} : { load: probed.load }) };
|
|
325
339
|
targetRuns.push({ bytes: probed.bytes, judgment });
|
|
326
340
|
for (const v of judgmentVerdicts(judgment)) judged.set(v.host, v);
|
|
327
341
|
}
|
|
@@ -76,6 +76,23 @@ export interface IsolatedFigures {
|
|
|
76
76
|
readonly midiInputsLinked?: number;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* What the pool step measured. With real helper threads under the interposer: the allocations, locks and system calls
|
|
81
|
+
* counted while the plugin's tasks ran on them, and how many tasks the helpers ran. Under a real host's pool on a private
|
|
82
|
+
* server: the server's DSP load and the xruns of the window without the thread pool and of the one with it.
|
|
83
|
+
*/
|
|
84
|
+
export interface PoolFigures {
|
|
85
|
+
readonly allocationsOn?: number;
|
|
86
|
+
readonly locksOn?: number;
|
|
87
|
+
readonly syscallsOn?: number;
|
|
88
|
+
readonly tasksOnHelpers?: number;
|
|
89
|
+
/** Percent of the cycle the whole graph took, over the last third of the window. */
|
|
90
|
+
readonly dspLoadOff?: number;
|
|
91
|
+
readonly dspLoadOn?: number;
|
|
92
|
+
readonly xrunsOff?: number;
|
|
93
|
+
readonly xrunsOn?: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
79
96
|
/** One step of a plugin's pass and how it ended. */
|
|
80
97
|
export interface RunStep {
|
|
81
98
|
readonly step: string;
|
|
@@ -83,6 +100,8 @@ export interface RunStep {
|
|
|
83
100
|
readonly code?: string;
|
|
84
101
|
readonly detail?: string;
|
|
85
102
|
readonly figures?: IsolatedFigures;
|
|
103
|
+
/** The pool step's figures. */
|
|
104
|
+
readonly pool?: PoolFigures;
|
|
86
105
|
}
|
|
87
106
|
|
|
88
107
|
/** The pass that judged a record: its id, and every step that ran. */
|
|
@@ -150,6 +169,25 @@ export function unmeasuredFigures(output: ProbeOutput): readonly UnmeasuredFigur
|
|
|
150
169
|
}));
|
|
151
170
|
}
|
|
152
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Whether the run that wrote the record never took the latency figure: the probe ended well and measured a point, yet no
|
|
174
|
+
* point holds a latency and none is named unmeasured. That is a probe from before the latency pass (plugin-hostd-probe
|
|
175
|
+
* 0.12 ran no latency pass on an LV2 plugin): an instrument's note onset, or an effect's impulse, was never asked for, so a
|
|
176
|
+
* reader shows "latency unknown" for a figure today's probe takes. Such a record owes a measurement; it is never read as
|
|
177
|
+
* a plugin with no latency.
|
|
178
|
+
*/
|
|
179
|
+
export function latencyNeverTaken(record: Pick<QualificationRecord, "probe" | "measurements">): boolean {
|
|
180
|
+
if (record.probe?.outcome !== PROBE_OK) return false;
|
|
181
|
+
const ok = (record.measurements?.grid ?? []).filter((p) => p.outcome === PROBE_OK);
|
|
182
|
+
if (ok.length === 0) return false;
|
|
183
|
+
if ((record.measurements.unmeasured ?? []).some((u) => u.dimension === "latency")) return false;
|
|
184
|
+
// a point's latency is a figure, or the reason the probe gave for none; `not-attempted` with no step is the older probe's silence
|
|
185
|
+
return !ok.some((p) => {
|
|
186
|
+
const l = p.latency as Partial<{ measuredFrames: number; unmeasuredReason: string }> | undefined;
|
|
187
|
+
return l !== undefined && (l.measuredFrames !== undefined || (l.unmeasuredReason !== undefined && l.unmeasuredReason !== word("notAttempted")));
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
153
191
|
/** A cell's latency when the run took no figure: the contract's unmeasured word and the hosting code a reader classifies by. */
|
|
154
192
|
export interface UnmeasuredLatency {
|
|
155
193
|
/** The figure the plugin declared (LV2 latency port, CLAP latency extension) when it loaded but the probe did not measure. */
|
|
@@ -343,9 +381,38 @@ export interface QualificationRecord {
|
|
|
343
381
|
readonly machineClass: Readonly<Record<string, string | boolean>>;
|
|
344
382
|
/** Whether the machine met the protocol's optimised profile: a figure that did not is not a reference figure. */
|
|
345
383
|
readonly meetsProtocol: boolean;
|
|
384
|
+
/** Each step whose child did not end well: the signal that killed it or the status it exited with, so a crash is
|
|
385
|
+
* told from the runner's own kill (a deadline or memory kill is SIGKILL). Absent when every child ended well. */
|
|
386
|
+
readonly ends?: readonly ProbeEnd[];
|
|
387
|
+
/** How loaded the machine was when the probe started; absent on a record written before it was taken. */
|
|
388
|
+
readonly load?: { readonly loadavg1: number; readonly cores: number };
|
|
346
389
|
};
|
|
347
390
|
}
|
|
348
391
|
|
|
392
|
+
/** How one step's child ended, when not well. */
|
|
393
|
+
export interface ProbeEnd {
|
|
394
|
+
readonly step: string;
|
|
395
|
+
readonly outcome: string;
|
|
396
|
+
readonly rate?: number;
|
|
397
|
+
readonly quantum?: number;
|
|
398
|
+
readonly signal?: string;
|
|
399
|
+
readonly exitStatus?: number;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** The steps of a probe output whose child was killed by a signal or exited with a status other than 0. */
|
|
403
|
+
export function probeEnds(output: ProbeOutput): readonly ProbeEnd[] {
|
|
404
|
+
return output.steps
|
|
405
|
+
.filter((s) => (s.signal !== undefined && s.signal !== null && s.signal !== "") || (s.exitStatus !== undefined && s.exitStatus !== 0))
|
|
406
|
+
.map((s) => ({
|
|
407
|
+
step: s.step,
|
|
408
|
+
outcome: s.outcome,
|
|
409
|
+
...(s.rate === undefined ? {} : { rate: s.rate }),
|
|
410
|
+
...(s.quantum === undefined ? {} : { quantum: s.quantum }),
|
|
411
|
+
...(s.signal ? { signal: s.signal } : {}),
|
|
412
|
+
...(s.exitStatus === undefined || s.exitStatus === 0 ? {} : { exitStatus: s.exitStatus }),
|
|
413
|
+
}));
|
|
414
|
+
}
|
|
415
|
+
|
|
349
416
|
/** One dimension a run did not measure, and the code that says so. */
|
|
350
417
|
export interface NotMeasured {
|
|
351
418
|
readonly dimension: string;
|
|
@@ -431,6 +498,8 @@ export function buildQualificationRecord(
|
|
|
431
498
|
complete: o.run.complete,
|
|
432
499
|
machineClass: o.machine.class,
|
|
433
500
|
meetsProtocol: o.machine.profile.meetsProtocol,
|
|
501
|
+
...(probeEnds(o).length === 0 ? {} : { ends: probeEnds(o) }),
|
|
502
|
+
...(judgment.load === undefined ? {} : { load: judgment.load }),
|
|
434
503
|
},
|
|
435
504
|
};
|
|
436
505
|
}
|