@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.
Files changed (102) hide show
  1. package/README.md +19 -730
  2. package/bin/plugin-qualify-sync.mjs +4 -2
  3. package/bin/plugin-qualify.mjs +7 -0
  4. package/dist/clap-qualify.d.ts +1 -1
  5. package/dist/compare.d.ts +311 -0
  6. package/dist/compare.js +502 -0
  7. package/dist/compare.js.map +1 -0
  8. package/dist/distributed-records.d.ts +0 -2
  9. package/dist/distributed-records.js +0 -2
  10. package/dist/distributed-records.js.map +1 -1
  11. package/dist/host-admission.d.ts +2 -2
  12. package/dist/host-admission.js +7 -0
  13. package/dist/host-admission.js.map +1 -1
  14. package/dist/hosting-codes.generated.d.ts +1 -0
  15. package/dist/hosting-codes.generated.js +1 -0
  16. package/dist/hosting-codes.generated.js.map +1 -1
  17. package/dist/hosting-suitability.d.ts +3 -1
  18. package/dist/hosting-suitability.js +9 -0
  19. package/dist/hosting-suitability.js.map +1 -1
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.js +1 -0
  22. package/dist/index.js.map +1 -1
  23. package/dist/jack-registry.d.ts +6 -0
  24. package/dist/jack-registry.js +52 -0
  25. package/dist/jack-registry.js.map +1 -0
  26. package/dist/lv2-loader.js +6 -5
  27. package/dist/lv2-loader.js.map +1 -1
  28. package/dist/measurement/format.d.ts +15 -0
  29. package/dist/measurement/format.js +19 -0
  30. package/dist/measurement/format.js.map +1 -1
  31. package/dist/measurement/sweep-jsonl.js +5 -1
  32. package/dist/measurement/sweep-jsonl.js.map +1 -1
  33. package/dist/measurement-grid.js +1 -1
  34. package/dist/pins-file.d.ts +22 -2
  35. package/dist/pins-file.js +118 -17
  36. package/dist/pins-file.js.map +1 -1
  37. package/dist/probe-judge.d.ts +5 -0
  38. package/dist/probe-judge.js.map +1 -1
  39. package/dist/probe-runner.d.ts +8 -0
  40. package/dist/probe-runner.js +8 -4
  41. package/dist/probe-runner.js.map +1 -1
  42. package/dist/qualification-record.d.ts +45 -0
  43. package/dist/qualification-record.js +36 -0
  44. package/dist/qualification-record.js.map +1 -1
  45. package/dist/qualify-adapter-pass.js +20 -5
  46. package/dist/qualify-adapter-pass.js.map +1 -1
  47. package/dist/qualify-declarations.d.ts +6 -0
  48. package/dist/qualify-declarations.js +8 -0
  49. package/dist/qualify-declarations.js.map +1 -1
  50. package/dist/qualify-isolated.d.ts +16 -3
  51. package/dist/qualify-isolated.js +31 -3
  52. package/dist/qualify-isolated.js.map +1 -1
  53. package/dist/qualify-pool.d.ts +129 -0
  54. package/dist/qualify-pool.js +290 -0
  55. package/dist/qualify-pool.js.map +1 -0
  56. package/dist/qualify-rejudge.js +7 -0
  57. package/dist/qualify-rejudge.js.map +1 -1
  58. package/dist/qualify-sync.d.ts +46 -1
  59. package/dist/qualify-sync.js +184 -20
  60. package/dist/qualify-sync.js.map +1 -1
  61. package/dist/records-package.d.ts +5 -0
  62. package/dist/records-package.js +25 -8
  63. package/dist/records-package.js.map +1 -1
  64. package/fixtures/clap-scan/fake.json +8 -2
  65. package/fixtures/probe-output/success-msi.json +2 -2
  66. package/package.json +3 -3
  67. package/src/clap-qualify.ts +1 -1
  68. package/src/compare.ts +699 -0
  69. package/src/distributed-records.ts +0 -3
  70. package/src/host-admission.ts +9 -2
  71. package/src/hosting-codes.generated.ts +1 -0
  72. package/src/hosting-suitability.ts +14 -0
  73. package/src/index.ts +1 -0
  74. package/src/jack-registry.ts +49 -0
  75. package/src/lv2-loader.ts +5 -4
  76. package/src/measurement/format.ts +32 -0
  77. package/src/measurement/sweep-jsonl.ts +5 -1
  78. package/src/measurement-grid.ts +1 -1
  79. package/src/pins-file.ts +109 -16
  80. package/src/probe-judge.ts +2 -0
  81. package/src/probe-runner.ts +18 -4
  82. package/src/qualification-record.ts +69 -0
  83. package/src/qualify-adapter-pass.ts +18 -5
  84. package/src/qualify-declarations.ts +8 -0
  85. package/src/qualify-isolated.ts +31 -5
  86. package/src/qualify-pool.ts +318 -0
  87. package/src/qualify-rejudge.ts +8 -0
  88. package/src/qualify-sync.ts +187 -19
  89. package/src/records-package.ts +25 -8
  90. package/tools/benchmark.py +9 -3
  91. package/tools/compare-host.mjs +432 -0
  92. package/tools/compare-run.mjs +355 -0
  93. package/tools/descriptors.py +27 -0
  94. package/tools/isolated-step.mjs +3 -1
  95. package/tools/lilv_load.py +25 -0
  96. package/tools/lv2_resolve.py +3 -1
  97. package/tools/pool-step.mjs +186 -0
  98. package/tools/scan.py +16 -20
  99. package/tools/audit-overlap.sh +0 -250
  100. package/tools/commit-message-check.sh +0 -128
  101. package/tools/commit-message-patterns.json +0 -41
  102. 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;
@@ -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
@@ -39,3 +39,4 @@ export * from "./host-admission.js";
39
39
  export * from "./history-file.js";
40
40
  export * from "./history-adjust.js";
41
41
  export * from "./adjust-cli.js";
42
+ export * from "./compare.js";
@@ -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 else shaped like a URI (`scheme:rest`) is one
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
- if (target.includes("/") || target.endsWith(".lv2")) return undefined; // a path that is not there
173
- return URI_SHAPE.test(target) ? [{ format: "lv2", uri: target }] : undefined;
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),
@@ -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; README "The measurement grid" states the three ways of choosing it and the order the cells are taken in.
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
- /** The line of one pin, or `undefined` when the integrity block is not a whole pin. A uri with whitespace in it would be read back as more words: it is refused, by name. */
60
- export function pinLine(entry: PinEntry): string | undefined {
61
- const spaced = [entry.uri, ...(entry.integrity.files ?? []).map((f) => f.path)].find((w) => /\s/.test(w));
62
- if (spaced !== undefined) {
63
- throw new Error(`${entry.uri}: ${JSON.stringify(spaced)} has whitespace in it and cannot be a word of a pin line`);
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
- return `${PIN_VERB} ${entry.uri} ${list} ${layout.scheme}:${layout.sha256}${verdict}`;
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 has whitespace in it, each with the reason: they have no 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.trim().split(/\s+/).filter((w) => w !== "");
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 !== 3 && words.length !== 4) throw new Error(`a pin line has 3 or 4 words, not ${words.length}`);
192
- const [uri, list, layout, verdictWord] = words as [string, string, string, string | undefined];
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
- if (verdictWord !== undefined) {
209
- const word = verdictWord.startsWith(PIN_VERDICT_KEY) ? verdictWord.slice(PIN_VERDICT_KEY.length) : "";
210
- if (!(PIN_VERDICTS as readonly string[]).includes(word)) throw new Error(`${verdictWord}: not a verdict`);
211
- verdict = word as PinVerdict;
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 { uri, files, layout: { scheme: layout.slice(0, colon), sha256: layout.slice(colon + 1) }, ...(verdict === undefined ? {} : { verdict }) };
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
  }
@@ -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[];
@@ -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 ? plain : { ...plain, adapter };
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
  }