@openmixer/plugin-qualify 0.10.1 → 0.11.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 +46 -10
- package/bin/plugin-qualify.mjs +61 -28
- package/dist/clap-loader.d.ts +1 -1
- package/dist/clap-loader.js +3 -3
- package/dist/clap-loader.js.map +1 -1
- package/dist/clap-qualify.d.ts +0 -47
- package/dist/clap-qualify.js +2 -88
- package/dist/clap-qualify.js.map +1 -1
- package/dist/cost-fields.d.ts +26 -0
- package/dist/cost-fields.js +30 -0
- package/dist/cost-fields.js.map +1 -1
- package/dist/hosting-codes.d.ts +1 -0
- package/dist/hosting-codes.js +1 -0
- package/dist/hosting-codes.js.map +1 -1
- package/dist/hosting-suitability.d.ts +11 -1
- package/dist/hosting-suitability.js +11 -2
- 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/judge-cli.d.ts +41 -0
- package/dist/judge-cli.js +114 -0
- package/dist/judge-cli.js.map +1 -0
- package/dist/measurement/probe-json.js +1 -0
- package/dist/measurement/probe-json.js.map +1 -1
- package/dist/measurement/vocabulary.js +6 -0
- package/dist/measurement/vocabulary.js.map +1 -1
- package/dist/probe-judge.d.ts +60 -0
- package/dist/probe-judge.js +213 -0
- package/dist/probe-judge.js.map +1 -0
- package/dist/probe-output-fixtures.d.ts +25 -0
- package/dist/probe-output-fixtures.js +134 -0
- package/dist/probe-output-fixtures.js.map +1 -0
- package/dist/probe-output.d.ts +150 -0
- package/dist/probe-output.js +369 -0
- package/dist/probe-output.js.map +1 -0
- package/dist/probe-runner.d.ts +79 -0
- package/dist/probe-runner.js +191 -0
- package/dist/probe-runner.js.map +1 -0
- package/dist/qualification-record.d.ts +80 -0
- package/dist/qualification-record.js +85 -0
- package/dist/qualification-record.js.map +1 -0
- package/dist/qualify-cli.js +1 -1
- package/dist/qualify-cli.js.map +1 -1
- package/dist/qualify-declarations.d.ts +11 -2
- package/dist/qualify-declarations.js +14 -4
- package/dist/qualify-declarations.js.map +1 -1
- package/fixtures/clap-scan/fake.json +30 -6
- package/fixtures/probe-output/memory-limit-refused.json +190 -0
- package/fixtures/probe-output/plugin-crash.json +236 -0
- package/fixtures/probe-output/success-msi.json +363 -0
- package/package.json +5 -1
- package/src/clap-loader.ts +3 -2
- package/src/clap-qualify.ts +2 -133
- package/src/cost-fields.ts +39 -0
- package/src/hosting-codes.ts +1 -0
- package/src/hosting-suitability.ts +21 -3
- package/src/index.ts +5 -0
- package/src/judge-cli.ts +133 -0
- package/src/measurement/probe-json.ts +1 -0
- package/src/measurement/vocabulary.ts +7 -0
- package/src/probe-judge.ts +264 -0
- package/src/probe-output-fixtures.ts +146 -0
- package/src/probe-output.ts +505 -0
- package/src/probe-runner.ts +244 -0
- package/src/qualification-record.ts +142 -0
- package/src/qualify-cli.ts +1 -1
- package/src/qualify-declarations.ts +16 -4
- package/tools/benchmark.py +12 -0
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
// Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
|
|
3
|
+
/**
|
|
4
|
+
* The reader of plugin-hostd's probe output (`plugin-hostd.probe-output`): the document
|
|
5
|
+
* `plugin-hostd-probe run` writes about one plugin binary and one offer. It holds facts only, so
|
|
6
|
+
* this module reads and checks; it never judges (`probe-judge.ts` does).
|
|
7
|
+
*
|
|
8
|
+
* The reader is structural, as `measurement/probe-json.ts` is: it validates what plugin-qualify
|
|
9
|
+
* needs and ignores the rest, so a newer minor of the format, which only adds fields, still reads.
|
|
10
|
+
* It refuses three things, each with a code that is never reworded:
|
|
11
|
+
*
|
|
12
|
+
* - `probe.format.major-unknown`: a major version this reader does not know. The check comes
|
|
13
|
+
* before the content is looked at, since a new major may change what the content means.
|
|
14
|
+
* - `probe.format.derived-mismatch`: `machine.class.cstatesLimited` or `machine.profile.meetsProtocol`
|
|
15
|
+
* disagrees with the fields they are computed from. Both are kept in the document because the
|
|
16
|
+
* machine class is shared; neither is trusted, both are recomputed here.
|
|
17
|
+
* - `probe.format.invalid`: anything else that is not the document: not JSON, a verdict inside it
|
|
18
|
+
* (the format has no field for one), a missing required block, a malformed figure.
|
|
19
|
+
*/
|
|
20
|
+
import { createHash } from "node:crypto";
|
|
21
|
+
|
|
22
|
+
import { finiteNumber, isRecord, nonEmptyString, optional } from "./measurement/guards.js";
|
|
23
|
+
|
|
24
|
+
export const PROBE_OUTPUT_FORMAT = "plugin-hostd.probe-output";
|
|
25
|
+
/** The one major this reader knows. A minor of it only adds fields. */
|
|
26
|
+
export const PROBE_OUTPUT_MAJOR = 1;
|
|
27
|
+
|
|
28
|
+
export const PROBE_FORMAT_CODE = {
|
|
29
|
+
majorUnknown: "probe.format.major-unknown",
|
|
30
|
+
derivedMismatch: "probe.format.derived-mismatch",
|
|
31
|
+
invalid: "probe.format.invalid",
|
|
32
|
+
} as const;
|
|
33
|
+
export type ProbeFormatCode = (typeof PROBE_FORMAT_CODE)[keyof typeof PROBE_FORMAT_CODE];
|
|
34
|
+
|
|
35
|
+
/** The outcome that means a step ran to its end. */
|
|
36
|
+
export const PROBE_OK = "probe.ok";
|
|
37
|
+
export const PROBE_NOT_ATTEMPTED = "probe.not-attempted";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* An exit-latency above this many microseconds makes an idle state deep. The runner writes the
|
|
41
|
+
* document by this rule, so the reader recomputes by it; the protocol document will state it
|
|
42
|
+
* (plugin-hostd#78) and this number follows that document.
|
|
43
|
+
*/
|
|
44
|
+
export const DEEP_IDLE_LATENCY_US = 20;
|
|
45
|
+
|
|
46
|
+
/** The keys the format has no room for: a document holding one is a judgment, not a fact. */
|
|
47
|
+
const JUDGMENT_KEYS = ["verdict", "verdicts", "rating", "hosting"] as const;
|
|
48
|
+
|
|
49
|
+
const SHA256 = /^[0-9a-f]{64}$/;
|
|
50
|
+
const OUTCOME = /^probe\.[a-z][a-z0-9.-]*$/;
|
|
51
|
+
|
|
52
|
+
export interface ProbeFile {
|
|
53
|
+
readonly path: string;
|
|
54
|
+
readonly sha256: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ProbeCost {
|
|
58
|
+
readonly median: number;
|
|
59
|
+
/** The percentile the figure is taken at, and its value, in nanoseconds per sample. */
|
|
60
|
+
readonly percentile: { readonly p: number; readonly value: number };
|
|
61
|
+
readonly blocks: number;
|
|
62
|
+
readonly warmupBlocks: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ProbeRtSafety {
|
|
66
|
+
readonly interposer: boolean;
|
|
67
|
+
readonly swept?: boolean;
|
|
68
|
+
readonly sweepApplicable?: boolean;
|
|
69
|
+
readonly blocks?: number;
|
|
70
|
+
readonly allocations?: number;
|
|
71
|
+
readonly locks?: number;
|
|
72
|
+
readonly syscalls?: number;
|
|
73
|
+
readonly threadViolations?: number;
|
|
74
|
+
readonly processErrors?: number;
|
|
75
|
+
readonly events?: readonly string[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface ProbeNoteSweep {
|
|
79
|
+
readonly applicable: boolean;
|
|
80
|
+
readonly dialect?: string;
|
|
81
|
+
readonly allocations?: number;
|
|
82
|
+
readonly locks?: number;
|
|
83
|
+
readonly syscalls?: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** One point of the protocol's grid: the figures at one rate and one quantum. */
|
|
87
|
+
export interface ProbeGridPoint {
|
|
88
|
+
readonly rate: number;
|
|
89
|
+
readonly quantum: number;
|
|
90
|
+
readonly outcome: string;
|
|
91
|
+
readonly latency?: { readonly declaredFrames: number; readonly measuredFrames: number };
|
|
92
|
+
readonly cost?: ProbeCost;
|
|
93
|
+
readonly rtSafety?: ProbeRtSafety;
|
|
94
|
+
readonly noteSweep?: ProbeNoteSweep;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface ProbeStep {
|
|
98
|
+
readonly step: string;
|
|
99
|
+
readonly outcome: string;
|
|
100
|
+
readonly rate?: number;
|
|
101
|
+
readonly quantum?: number;
|
|
102
|
+
readonly detail?: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface ProbeIdleState {
|
|
106
|
+
readonly cpu: number;
|
|
107
|
+
readonly state: string;
|
|
108
|
+
readonly latencyUs: number;
|
|
109
|
+
readonly disabled: boolean;
|
|
110
|
+
readonly usageDelta: number;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface ProbeOutput {
|
|
114
|
+
readonly formatVersion: string;
|
|
115
|
+
readonly run: {
|
|
116
|
+
readonly id: string;
|
|
117
|
+
readonly startedAt: string;
|
|
118
|
+
readonly elapsedMs: number;
|
|
119
|
+
readonly complete: boolean;
|
|
120
|
+
readonly outcome: string;
|
|
121
|
+
readonly reason?: string;
|
|
122
|
+
readonly protocol: { readonly name: string; readonly version: number };
|
|
123
|
+
readonly tools: Readonly<Record<string, string>>;
|
|
124
|
+
readonly via: string;
|
|
125
|
+
};
|
|
126
|
+
readonly plugin: {
|
|
127
|
+
readonly format: "clap" | "lv2";
|
|
128
|
+
readonly id: string;
|
|
129
|
+
readonly name?: string;
|
|
130
|
+
readonly version?: string;
|
|
131
|
+
};
|
|
132
|
+
readonly binary: {
|
|
133
|
+
readonly path: string;
|
|
134
|
+
readonly sha256: string;
|
|
135
|
+
readonly files: readonly ProbeFile[];
|
|
136
|
+
readonly layout?: { readonly scheme: string; readonly sha256: string };
|
|
137
|
+
/** Why the files cannot be pinned, when they cannot. */
|
|
138
|
+
readonly unpinnable?: string;
|
|
139
|
+
};
|
|
140
|
+
readonly machine: {
|
|
141
|
+
readonly class: Readonly<Record<string, string | boolean>>;
|
|
142
|
+
readonly profile: {
|
|
143
|
+
readonly governor: string | null;
|
|
144
|
+
readonly cpuidle: readonly ProbeIdleState[];
|
|
145
|
+
readonly meetsProtocol: boolean;
|
|
146
|
+
readonly unmet: readonly string[];
|
|
147
|
+
};
|
|
148
|
+
readonly pinnedCpus: string;
|
|
149
|
+
};
|
|
150
|
+
readonly offer: { readonly id: string; readonly clapExtensions?: readonly string[] };
|
|
151
|
+
readonly steps: readonly ProbeStep[];
|
|
152
|
+
readonly grid: readonly ProbeGridPoint[];
|
|
153
|
+
/** What the document says about how the plugin behaves, as it was written. */
|
|
154
|
+
readonly behaviour?: Readonly<Record<string, unknown>>;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export type ReadProbeOutputResult =
|
|
158
|
+
| { readonly ok: true; readonly output: ProbeOutput }
|
|
159
|
+
| { readonly ok: false; readonly code: ProbeFormatCode; readonly problem: string };
|
|
160
|
+
|
|
161
|
+
class Refusal extends Error {
|
|
162
|
+
constructor(
|
|
163
|
+
readonly code: ProbeFormatCode,
|
|
164
|
+
message: string,
|
|
165
|
+
) {
|
|
166
|
+
super(message);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function invalid(what: string): never {
|
|
171
|
+
throw new Refusal(PROBE_FORMAT_CODE.invalid, what);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function record(v: unknown, where: string): Record<string, unknown> {
|
|
175
|
+
return isRecord(v) ? v : invalid(`${where} is not an object`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function str(o: Record<string, unknown>, key: string, where: string): string {
|
|
179
|
+
return nonEmptyString(o[key]) ?? invalid(`${where} has no ${key}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function bool(o: Record<string, unknown>, key: string, where: string): boolean {
|
|
183
|
+
const v = o[key];
|
|
184
|
+
return typeof v === "boolean" ? v : invalid(`${where} has no boolean ${key}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function num(o: Record<string, unknown>, key: string, where: string): number {
|
|
188
|
+
return finiteNumber(o[key]) ?? invalid(`${where} has no number ${key}`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function posInt(o: Record<string, unknown>, key: string, where: string): number {
|
|
192
|
+
const v = o[key];
|
|
193
|
+
return typeof v === "number" && Number.isSafeInteger(v) && v > 0 ? v : invalid(`${where} has no positive integer ${key}`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function count(o: Record<string, unknown>, key: string, where: string): number {
|
|
197
|
+
const v = o[key];
|
|
198
|
+
return typeof v === "number" && Number.isSafeInteger(v) && v >= 0 ? v : invalid(`${where} has no count ${key}`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function optCount(o: Record<string, unknown>, key: string, where: string): number | undefined {
|
|
202
|
+
return o[key] === undefined ? undefined : count(o, key, where);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function optBool(o: Record<string, unknown>, key: string, where: string): boolean | undefined {
|
|
206
|
+
return o[key] === undefined ? undefined : bool(o, key, where);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function outcome(o: Record<string, unknown>, where: string): string {
|
|
210
|
+
const v = str(o, "outcome", where);
|
|
211
|
+
return OUTCOME.test(v) ? v : invalid(`${where} outcome ${v} is not a probe code`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function list(o: Record<string, unknown>, key: string, where: string): unknown[] {
|
|
215
|
+
const v = o[key];
|
|
216
|
+
return Array.isArray(v) ? v : invalid(`${where} has no list ${key}`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function sha(o: Record<string, unknown>, key: string, where: string): string {
|
|
220
|
+
const v = str(o, key, where);
|
|
221
|
+
return SHA256.test(v) ? v : invalid(`${where} ${key} is not 64 lowercase hex digits`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** The strings of a list that holds nothing else, or `undefined`. */
|
|
225
|
+
function stringList(v: unknown): string[] | undefined {
|
|
226
|
+
if (!Array.isArray(v)) return undefined;
|
|
227
|
+
const out: string[] = [];
|
|
228
|
+
for (const item of v) {
|
|
229
|
+
if (typeof item !== "string") return undefined;
|
|
230
|
+
out.push(item);
|
|
231
|
+
}
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function readCost(v: unknown, where: string): ProbeCost {
|
|
236
|
+
const o = record(v, where);
|
|
237
|
+
const pct = record(o["percentile"], `${where} percentile`);
|
|
238
|
+
const p = num(pct, "p", `${where} percentile`);
|
|
239
|
+
if (!(p > 50 && p <= 100)) invalid(`${where} percentile p ${p} is outside (50, 100]`);
|
|
240
|
+
const median = num(o, "median", where);
|
|
241
|
+
const value = num(pct, "value", `${where} percentile`);
|
|
242
|
+
if (median < 0 || value < 0) invalid(`${where} holds a negative cost`);
|
|
243
|
+
return {
|
|
244
|
+
median,
|
|
245
|
+
percentile: { p, value },
|
|
246
|
+
blocks: posInt(o, "blocks", where),
|
|
247
|
+
warmupBlocks: count(o, "warmupBlocks", where),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function readRtSafety(v: unknown, where: string): ProbeRtSafety {
|
|
252
|
+
const o = record(v, where);
|
|
253
|
+
const events = o["events"] === undefined ? undefined : (stringList(o["events"]) ?? invalid(`${where} events is not a list of strings`));
|
|
254
|
+
return {
|
|
255
|
+
interposer: bool(o, "interposer", where),
|
|
256
|
+
...optional("swept", optBool(o, "swept", where)),
|
|
257
|
+
...optional("sweepApplicable", optBool(o, "sweepApplicable", where)),
|
|
258
|
+
...optional("blocks", optCount(o, "blocks", where)),
|
|
259
|
+
...optional("allocations", optCount(o, "allocations", where)),
|
|
260
|
+
...optional("locks", optCount(o, "locks", where)),
|
|
261
|
+
...optional("syscalls", optCount(o, "syscalls", where)),
|
|
262
|
+
...optional("threadViolations", optCount(o, "threadViolations", where)),
|
|
263
|
+
...optional("processErrors", optCount(o, "processErrors", where)),
|
|
264
|
+
...optional("events", events),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function readNoteSweep(v: unknown, where: string): ProbeNoteSweep {
|
|
269
|
+
const o = record(v, where);
|
|
270
|
+
return {
|
|
271
|
+
applicable: bool(o, "applicable", where),
|
|
272
|
+
...optional("dialect", nonEmptyString(o["dialect"])),
|
|
273
|
+
...optional("allocations", optCount(o, "allocations", where)),
|
|
274
|
+
...optional("locks", optCount(o, "locks", where)),
|
|
275
|
+
...optional("syscalls", optCount(o, "syscalls", where)),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function readPoint(v: unknown, i: number): ProbeGridPoint {
|
|
280
|
+
const where = `grid[${i}]`;
|
|
281
|
+
const o = record(v, where);
|
|
282
|
+
const latencyIn = o["latency"];
|
|
283
|
+
let latency: ProbeGridPoint["latency"];
|
|
284
|
+
if (latencyIn !== undefined) {
|
|
285
|
+
const l = record(latencyIn, `${where} latency`);
|
|
286
|
+
latency = { declaredFrames: count(l, "declaredFrames", `${where} latency`), measuredFrames: count(l, "measuredFrames", `${where} latency`) };
|
|
287
|
+
}
|
|
288
|
+
return {
|
|
289
|
+
rate: posInt(o, "rate", where),
|
|
290
|
+
quantum: posInt(o, "quantum", where),
|
|
291
|
+
outcome: outcome(o, where),
|
|
292
|
+
...optional("latency", latency),
|
|
293
|
+
...(o["cost"] === undefined ? {} : { cost: readCost(o["cost"], `${where} cost`) }),
|
|
294
|
+
...(o["rtSafety"] === undefined ? {} : { rtSafety: readRtSafety(o["rtSafety"], `${where} rtSafety`) }),
|
|
295
|
+
...(o["noteSweep"] === undefined ? {} : { noteSweep: readNoteSweep(o["noteSweep"], `${where} noteSweep`) }),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function readStep(v: unknown, i: number): ProbeStep {
|
|
300
|
+
const where = `steps[${i}]`;
|
|
301
|
+
const o = record(v, where);
|
|
302
|
+
return {
|
|
303
|
+
step: str(o, "step", where),
|
|
304
|
+
outcome: outcome(o, where),
|
|
305
|
+
...optional("rate", o["rate"] === undefined ? undefined : posInt(o, "rate", where)),
|
|
306
|
+
...optional("quantum", o["quantum"] === undefined ? undefined : posInt(o, "quantum", where)),
|
|
307
|
+
...optional("detail", nonEmptyString(o["detail"])),
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function readIdle(v: unknown, i: number): ProbeIdleState {
|
|
312
|
+
const where = `machine.profile.cpuidle[${i}]`;
|
|
313
|
+
const o = record(v, where);
|
|
314
|
+
return {
|
|
315
|
+
cpu: count(o, "cpu", where),
|
|
316
|
+
state: str(o, "state", where),
|
|
317
|
+
latencyUs: count(o, "latencyUs", where),
|
|
318
|
+
disabled: bool(o, "disabled", where),
|
|
319
|
+
usageDelta: count(o, "usageDelta", where),
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Whether any idle state deeper than {@link DEEP_IDLE_LATENCY_US} is enabled: then the machine does not limit its C-states. */
|
|
324
|
+
export function deepIdleEnabled(cpuidle: readonly ProbeIdleState[]): boolean {
|
|
325
|
+
return cpuidle.some((s) => s.latencyUs > DEEP_IDLE_LATENCY_US && !s.disabled);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Whether a deep idle state was entered while the run measured. */
|
|
329
|
+
export function deepIdleEntered(cpuidle: readonly ProbeIdleState[]): boolean {
|
|
330
|
+
return cpuidle.some((s) => s.latencyUs > DEEP_IDLE_LATENCY_US && s.usageDelta > 0);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const PROFILE_GOVERNOR = "probe.profile.governor";
|
|
334
|
+
const PROFILE_DEEP_IDLE = "probe.profile.deep-idle-entered";
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* The two derived fields, recomputed. `cstatesLimited` is "idle states are read and none deeper than
|
|
338
|
+
* the limit is enabled", and it cannot hold when a deep state was entered. `meetsProtocol` is the
|
|
339
|
+
* performance governor, no deep state entered and nothing else unmet: the codes the reader can
|
|
340
|
+
* recompute (`probe.profile.governor`, `probe.profile.deep-idle-entered`) must be in `unmet`, and the
|
|
341
|
+
* flag is true exactly when `unmet` is empty. A code the reader cannot recompute (the pinning, the
|
|
342
|
+
* turbo and SMT readings) is taken as the runner wrote it.
|
|
343
|
+
*/
|
|
344
|
+
function checkDerived(machine: ProbeOutput["machine"]): void {
|
|
345
|
+
const idle = machine.profile.cpuidle;
|
|
346
|
+
const limited = idle.length > 0 && !deepIdleEnabled(idle);
|
|
347
|
+
const claimed = machine.class["cstatesLimited"];
|
|
348
|
+
if (claimed !== limited) {
|
|
349
|
+
throw new Refusal(
|
|
350
|
+
PROBE_FORMAT_CODE.derivedMismatch,
|
|
351
|
+
`machine.class.cstatesLimited is ${String(claimed)} but cpuidle[] gives ${String(limited)}`,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
const { governor, unmet, meetsProtocol } = machine.profile;
|
|
355
|
+
const required = [...(governor === "performance" ? [] : [PROFILE_GOVERNOR]), ...(deepIdleEntered(idle) ? [PROFILE_DEEP_IDLE] : [])];
|
|
356
|
+
const missing = required.filter((code) => !unmet.includes(code));
|
|
357
|
+
if (missing.length > 0) {
|
|
358
|
+
throw new Refusal(PROBE_FORMAT_CODE.derivedMismatch, `machine.profile.unmet lacks ${missing.join(", ")}, which governor and cpuidle[] give`);
|
|
359
|
+
}
|
|
360
|
+
if (meetsProtocol !== (unmet.length === 0)) {
|
|
361
|
+
throw new Refusal(
|
|
362
|
+
PROBE_FORMAT_CODE.derivedMismatch,
|
|
363
|
+
`machine.profile.meetsProtocol is ${String(meetsProtocol)} but unmet[] holds ${unmet.length} code${unmet.length === 1 ? "" : "s"}`,
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function readMachine(v: unknown): ProbeOutput["machine"] {
|
|
369
|
+
const o = record(v, "machine");
|
|
370
|
+
const cls = record(o["class"], "machine.class");
|
|
371
|
+
const klass: Record<string, string | boolean> = {};
|
|
372
|
+
for (const [k, value] of Object.entries(cls)) if (typeof value === "string" || typeof value === "boolean") klass[k] = value;
|
|
373
|
+
if (typeof klass["cstatesLimited"] !== "boolean") invalid("machine.class has no boolean cstatesLimited");
|
|
374
|
+
const p = record(o["profile"], "machine.profile");
|
|
375
|
+
const governor = p["governor"];
|
|
376
|
+
if (!(governor === null || typeof governor === "string")) invalid("machine.profile.governor is not a string or null");
|
|
377
|
+
const unmet = p["unmet"] === undefined ? [] : (stringList(p["unmet"]) ?? invalid("machine.profile.unmet is not a list of strings"));
|
|
378
|
+
return {
|
|
379
|
+
class: klass,
|
|
380
|
+
profile: {
|
|
381
|
+
governor,
|
|
382
|
+
cpuidle: list(p, "cpuidle", "machine.profile").map(readIdle),
|
|
383
|
+
meetsProtocol: bool(p, "meetsProtocol", "machine.profile"),
|
|
384
|
+
unmet,
|
|
385
|
+
},
|
|
386
|
+
pinnedCpus: str(o, "pinnedCpus", "machine"),
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function readBinary(v: unknown): ProbeOutput["binary"] {
|
|
391
|
+
const o = record(v, "binary");
|
|
392
|
+
const files = list(o, "files", "binary").map((f, i) => {
|
|
393
|
+
const fo = record(f, `binary.files[${i}]`);
|
|
394
|
+
return { path: str(fo, "path", `binary.files[${i}]`), sha256: sha(fo, "sha256", `binary.files[${i}]`) };
|
|
395
|
+
});
|
|
396
|
+
const layoutIn = o["layout"];
|
|
397
|
+
let layout: { scheme: string; sha256: string } | undefined;
|
|
398
|
+
if (layoutIn !== undefined) {
|
|
399
|
+
const lo = record(layoutIn, "binary.layout");
|
|
400
|
+
layout = { scheme: str(lo, "scheme", "binary.layout"), sha256: sha(lo, "sha256", "binary.layout") };
|
|
401
|
+
}
|
|
402
|
+
return {
|
|
403
|
+
path: str(o, "path", "binary"),
|
|
404
|
+
sha256: sha(o, "sha256", "binary"),
|
|
405
|
+
files,
|
|
406
|
+
...optional("layout", layout),
|
|
407
|
+
...optional("unpinnable", nonEmptyString(o["unpinnable"])),
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function readRun(v: unknown): ProbeOutput["run"] {
|
|
412
|
+
const o = record(v, "run");
|
|
413
|
+
const protocol = record(o["protocol"], "run.protocol");
|
|
414
|
+
const toolsIn = record(o["tools"], "run.tools");
|
|
415
|
+
const tools: Record<string, string> = {};
|
|
416
|
+
for (const [k, value] of Object.entries(toolsIn)) if (typeof value === "string") tools[k] = value;
|
|
417
|
+
return {
|
|
418
|
+
id: str(o, "id", "run"),
|
|
419
|
+
startedAt: str(o, "startedAt", "run"),
|
|
420
|
+
elapsedMs: count(o, "elapsedMs", "run"),
|
|
421
|
+
complete: bool(o, "complete", "run"),
|
|
422
|
+
outcome: outcome(o, "run"),
|
|
423
|
+
...optional("reason", nonEmptyString(o["reason"])),
|
|
424
|
+
protocol: { name: str(protocol, "name", "run.protocol"), version: posInt(protocol, "version", "run.protocol") },
|
|
425
|
+
tools,
|
|
426
|
+
via: str(o, "via", "run"),
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function readPlugin(v: unknown): ProbeOutput["plugin"] {
|
|
431
|
+
const o = record(v, "plugin");
|
|
432
|
+
const format = o["format"];
|
|
433
|
+
if (format !== "clap" && format !== "lv2") invalid(`plugin.format ${JSON.stringify(format)} is neither clap nor lv2`);
|
|
434
|
+
return {
|
|
435
|
+
format,
|
|
436
|
+
id: str(o, "id", "plugin"),
|
|
437
|
+
...optional("name", nonEmptyString(o["name"])),
|
|
438
|
+
...optional("version", nonEmptyString(o["version"])),
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function readOffer(v: unknown): ProbeOutput["offer"] {
|
|
443
|
+
const o = record(v, "offer");
|
|
444
|
+
const ext = o["clapExtensions"] === undefined ? undefined : (stringList(o["clapExtensions"]) ?? invalid("offer.clapExtensions is not a list of strings"));
|
|
445
|
+
return { id: str(o, "id", "offer"), ...optional("clapExtensions", ext) };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** The version `"<major>.<minor>"`, or `undefined` when it is not one. */
|
|
449
|
+
function parseVersion(v: unknown): { major: number; minor: number } | undefined {
|
|
450
|
+
const m = typeof v === "string" ? /^(\d+)\.(\d+)$/.exec(v) : null;
|
|
451
|
+
return m === null ? undefined : { major: Number(m[1]), minor: Number(m[2]) };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* A probe output from its text, or the code that refuses it. The order of the checks is the order of
|
|
456
|
+
* what can be trusted: that it is JSON and the format, then the major (before any content is read),
|
|
457
|
+
* then that it holds no judgment, then the content, then the derived fields against it.
|
|
458
|
+
*/
|
|
459
|
+
export function readProbeOutput(text: string): ReadProbeOutputResult {
|
|
460
|
+
try {
|
|
461
|
+
let json: unknown;
|
|
462
|
+
try {
|
|
463
|
+
json = JSON.parse(text);
|
|
464
|
+
} catch {
|
|
465
|
+
return invalid("not JSON");
|
|
466
|
+
}
|
|
467
|
+
const doc = record(json, "the document");
|
|
468
|
+
if (doc["format"] !== PROBE_OUTPUT_FORMAT) invalid(`format is ${JSON.stringify(doc["format"])}, not ${PROBE_OUTPUT_FORMAT}`);
|
|
469
|
+
const version = parseVersion(doc["formatVersion"]);
|
|
470
|
+
if (version === undefined) invalid(`formatVersion ${JSON.stringify(doc["formatVersion"])} is not "<major>.<minor>"`);
|
|
471
|
+
if (version.major !== PROBE_OUTPUT_MAJOR) {
|
|
472
|
+
throw new Refusal(PROBE_FORMAT_CODE.majorUnknown, `formatVersion ${String(doc["formatVersion"])}: this reader knows major ${PROBE_OUTPUT_MAJOR}`);
|
|
473
|
+
}
|
|
474
|
+
for (const key of JUDGMENT_KEYS) if (key in doc) invalid(`the document carries ${key}: a probe output holds facts, never a judgment`);
|
|
475
|
+
const grid = doc["grid"] === undefined ? [] : list(doc, "grid", "the document").map(readPoint);
|
|
476
|
+
const seen = new Set<string>();
|
|
477
|
+
for (const p of grid) {
|
|
478
|
+
const key = `${p.rate}/${p.quantum}`;
|
|
479
|
+
if (seen.has(key)) invalid(`grid holds rate ${p.rate} at quantum ${p.quantum} twice`);
|
|
480
|
+
seen.add(key);
|
|
481
|
+
}
|
|
482
|
+
const behaviour = doc["behaviour"];
|
|
483
|
+
const output: ProbeOutput = {
|
|
484
|
+
formatVersion: String(doc["formatVersion"]),
|
|
485
|
+
run: readRun(doc["run"]),
|
|
486
|
+
plugin: readPlugin(doc["plugin"]),
|
|
487
|
+
binary: readBinary(doc["binary"]),
|
|
488
|
+
machine: readMachine(doc["machine"]),
|
|
489
|
+
offer: readOffer(doc["offer"]),
|
|
490
|
+
steps: list(doc, "steps", "the document").map(readStep),
|
|
491
|
+
grid,
|
|
492
|
+
...(isRecord(behaviour) ? { behaviour } : {}),
|
|
493
|
+
};
|
|
494
|
+
checkDerived(output.machine);
|
|
495
|
+
return { ok: true, output };
|
|
496
|
+
} catch (e) {
|
|
497
|
+
if (e instanceof Refusal) return { ok: false, code: e.code, problem: e.message };
|
|
498
|
+
throw e;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** The SHA-256 of some bytes, as the record names a probe output's copy. */
|
|
503
|
+
export function sha256Hex(bytes: Uint8Array): string {
|
|
504
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
505
|
+
}
|