@openmixer/plugin-qualify 0.18.5 → 0.20.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.
Files changed (95) hide show
  1. package/README.md +19 -730
  2. package/bin/plugin-qualify-sync.mjs +1 -1
  3. package/bin/plugin-qualify.mjs +9 -1
  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/lv2-loader.js +6 -5
  24. package/dist/lv2-loader.js.map +1 -1
  25. package/dist/measurement/format.d.ts +15 -0
  26. package/dist/measurement/format.js +19 -0
  27. package/dist/measurement/format.js.map +1 -1
  28. package/dist/measurement/sweep-jsonl.js +5 -1
  29. package/dist/measurement/sweep-jsonl.js.map +1 -1
  30. package/dist/measurement-grid.js +1 -1
  31. package/dist/pins-file.d.ts +22 -2
  32. package/dist/pins-file.js +118 -17
  33. package/dist/pins-file.js.map +1 -1
  34. package/dist/probe-merge.js +6 -0
  35. package/dist/probe-merge.js.map +1 -1
  36. package/dist/probe-runner.d.ts +4 -0
  37. package/dist/probe-runner.js +12 -3
  38. package/dist/probe-runner.js.map +1 -1
  39. package/dist/qualification-record.d.ts +33 -1
  40. package/dist/qualification-record.js +36 -3
  41. package/dist/qualification-record.js.map +1 -1
  42. package/dist/qualify-declarations.d.ts +6 -0
  43. package/dist/qualify-declarations.js +8 -0
  44. package/dist/qualify-declarations.js.map +1 -1
  45. package/dist/qualify-isolated.d.ts +16 -3
  46. package/dist/qualify-isolated.js +31 -3
  47. package/dist/qualify-isolated.js.map +1 -1
  48. package/dist/qualify-pool.d.ts +129 -0
  49. package/dist/qualify-pool.js +290 -0
  50. package/dist/qualify-pool.js.map +1 -0
  51. package/dist/qualify-rejudge.js +7 -0
  52. package/dist/qualify-rejudge.js.map +1 -1
  53. package/dist/qualify-sync.d.ts +37 -2
  54. package/dist/qualify-sync.js +148 -24
  55. package/dist/qualify-sync.js.map +1 -1
  56. package/dist/records-package.d.ts +5 -0
  57. package/dist/records-package.js +25 -8
  58. package/dist/records-package.js.map +1 -1
  59. package/fixtures/clap-scan/fake.json +8 -2
  60. package/fixtures/probe-output/success-msi.json +2 -2
  61. package/package.json +3 -3
  62. package/src/clap-qualify.ts +1 -1
  63. package/src/compare.ts +699 -0
  64. package/src/distributed-records.ts +0 -3
  65. package/src/host-admission.ts +9 -2
  66. package/src/hosting-codes.generated.ts +1 -0
  67. package/src/hosting-suitability.ts +14 -0
  68. package/src/index.ts +1 -0
  69. package/src/lv2-loader.ts +5 -4
  70. package/src/measurement/format.ts +32 -0
  71. package/src/measurement/sweep-jsonl.ts +5 -1
  72. package/src/measurement-grid.ts +1 -1
  73. package/src/pins-file.ts +109 -16
  74. package/src/probe-merge.ts +4 -0
  75. package/src/probe-runner.ts +16 -6
  76. package/src/qualification-record.ts +59 -4
  77. package/src/qualify-declarations.ts +8 -0
  78. package/src/qualify-isolated.ts +31 -5
  79. package/src/qualify-pool.ts +318 -0
  80. package/src/qualify-rejudge.ts +8 -0
  81. package/src/qualify-sync.ts +145 -24
  82. package/src/records-package.ts +25 -8
  83. package/tools/benchmark.py +9 -3
  84. package/tools/compare-host.mjs +432 -0
  85. package/tools/compare-run.mjs +355 -0
  86. package/tools/descriptors.py +27 -0
  87. package/tools/isolated-step.mjs +3 -1
  88. package/tools/lilv_load.py +25 -0
  89. package/tools/lv2_resolve.py +3 -1
  90. package/tools/pool-step.mjs +186 -0
  91. package/tools/scan.py +16 -20
  92. package/tools/audit-overlap.sh +0 -250
  93. package/tools/commit-message-check.sh +0 -128
  94. package/tools/commit-message-patterns.json +0 -41
  95. 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";
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
  }
@@ -83,6 +83,10 @@ export function mergeProbeRuns(parts: readonly ProbeRunPart[], bound: MemoryBoun
83
83
  };
84
84
  merged["steps"] = steps;
85
85
  merged["grid"] = grid;
86
+ // every run's stderr is cited, not only the first's: the cell that crashed is as often a later one
87
+ const logs = docs.flatMap((d) => (Array.isArray(d["logs"]) ? (d["logs"] as Json[]) : []));
88
+ if (logs.length > 0) merged["logs"] = logs;
89
+ else delete merged["logs"];
86
90
  const bytes = Buffer.from(`${JSON.stringify(merged, null, 2)}\n`);
87
91
  const read = readProbeOutput(bytes.toString("utf8"));
88
92
  if (!read.ok) throw new ProbeMergeError(`the merged runs of ${first.output.plugin.id} do not read as a probe output (${read.code}): ${read.problem}`);
@@ -165,6 +165,9 @@ export interface ProbeLoad {
165
165
  export interface ProbedTarget {
166
166
  readonly bytes: Buffer;
167
167
  readonly output: ProbeOutput;
168
+ /** What each run's child printed on stderr (the file the document's `logs` cites, read before the run's directory goes),
169
+ * one entry a run that printed anything: a crash's own words are kept with the record (#samplv1, 459 bytes lost on the Pi). */
170
+ readonly stderr: readonly Buffer[];
168
171
  /** The most loaded the machine was when any of the target's runs started. */
169
172
  readonly load?: ProbeLoad;
170
173
  }
@@ -226,7 +229,14 @@ function runOne(target: PluginTarget, options: ProbeTargetOptions, invocation: R
226
229
  if (read.output.plugin.id !== expectedId) {
227
230
  throw new ProbeRunnerError(`${tool} measured ${read.output.plugin.id} when asked for ${expectedId}: not judged as the plugin asked for`);
228
231
  }
229
- return { bytes, output: read.output, load };
232
+ let stderr: Buffer[] = [];
233
+ try {
234
+ const said = readFileSync(log);
235
+ if (said.length > 0) stderr = [said];
236
+ } catch {
237
+ // the runner wrote no log: the document cites none
238
+ }
239
+ return { bytes, output: read.output, load, stderr };
230
240
  } finally {
231
241
  rmSync(work, { recursive: true, force: true });
232
242
  }
@@ -246,7 +256,7 @@ export function probeTarget(target: PluginTarget, options: ProbeTargetOptions):
246
256
  const loads = parts.flatMap((p) => (p.load === undefined ? [] : [p.load]));
247
257
  const load = loads.length === 0 ? undefined : loads.reduce((a, b) => (b.loadavg1 > a.loadavg1 ? b : a));
248
258
  try {
249
- return { ...mergeProbeRuns(parts, options.memory), ...(load === undefined ? {} : { load }) };
259
+ return { ...mergeProbeRuns(parts, options.memory), stderr: parts.flatMap((p) => p.stderr), ...(load === undefined ? {} : { load }) };
250
260
  } catch (e) {
251
261
  if (e instanceof ProbeMergeError) throw new ProbeRunnerError(e.message);
252
262
  throw e;
@@ -256,7 +266,7 @@ export function probeTarget(target: PluginTarget, options: ProbeTargetOptions):
256
266
  /** What the pass over a run's targets produced. */
257
267
  export interface ProbePass {
258
268
  /** Every probe output that was judged, with its bytes, for the qualification directory. */
259
- readonly runs: readonly { readonly bytes: Buffer; readonly judgment: Judgment }[];
269
+ readonly runs: readonly { readonly bytes: Buffer; readonly judgment: Judgment; readonly stderr: readonly Buffer[] }[];
260
270
  readonly verdicts: readonly QualifyVerdict[];
261
271
  /** The CLAP documents of today's judge, which the pins file is taken from. */
262
272
  readonly documents: readonly RtSafetyDocument[];
@@ -292,7 +302,7 @@ export function qualifyTargets(
292
302
  const admissionRuns = options.admissions ?? SPAWNED_ADMISSIONS;
293
303
  const runEnvironment = options.env ?? process.env;
294
304
  const admissionEnv = options.admissionLv2Path === undefined ? runEnvironment : { ...runEnvironment, LV2_PATH: options.admissionLv2Path.join(":") };
295
- const runs: { bytes: Buffer; judgment: Judgment }[] = [];
305
+ const runs: { bytes: Buffer; judgment: Judgment; stderr: readonly Buffer[] }[] = [];
296
306
  const verdicts: QualifyVerdict[] = [];
297
307
  const documents: RtSafetyDocument[] = [];
298
308
  const failures: { uri: string; problem: string }[] = [];
@@ -330,13 +340,13 @@ export function qualifyTargets(
330
340
  const judged = new Map<string, QualifyVerdict>();
331
341
  // a target's runs and documents count only when every one of its runs gave a probe output: one that
332
342
  // failed leaves the target not measured, and then nothing of it is recorded or pinned
333
- const targetRuns: { bytes: Buffer; judgment: Judgment }[] = [];
343
+ const targetRuns: { bytes: Buffer; judgment: Judgment; stderr: readonly Buffer[] }[] = [];
334
344
  try {
335
345
  for (const { offer, profiles: forOffer } of byOffer.values()) {
336
346
  const probed = probeTarget(target, { ...options, ...measured, offer });
337
347
  const plain = judgeProbeOutput(probed.output, forOffer, { admissions });
338
348
  const judgment = { ...plain, ...(adapter === undefined ? {} : { adapter }), ...(probed.load === undefined ? {} : { load: probed.load }) };
339
- targetRuns.push({ bytes: probed.bytes, judgment });
349
+ targetRuns.push({ bytes: probed.bytes, judgment, stderr: probed.stderr });
340
350
  for (const v of judgmentVerdicts(judgment)) judged.set(v.host, v);
341
351
  }
342
352
  } catch (e) {
@@ -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. */
@@ -390,6 +428,8 @@ export function qualificationBaseName(output: ProbeOutput): string {
390
428
 
391
429
  export const recordFileName = (output: ProbeOutput): string => `${qualificationBaseName(output)}.qualification.json`;
392
430
  export const probeCopyFileName = (output: ProbeOutput): string => `${qualificationBaseName(output)}.probe.json`;
431
+ /** A child's stderr beside the record, named by the first 12 of the SHA-256 the probe copy's `logs` cites it by. */
432
+ export const stderrCopyFileName = (output: ProbeOutput, sha256: string): string => `${qualificationBaseName(output)}.stderr.${sha256.slice(0, 12)}.log`;
393
433
 
394
434
  function tierOf(judgment: Judgment, host: string | undefined): TierVerdict | undefined {
395
435
  const v = judgment.hosts.find((h) => h.verdict.host === host)?.verdict;
@@ -480,6 +520,8 @@ export interface WrittenQualification {
480
520
  readonly recordPath: string;
481
521
  readonly probePath: string;
482
522
  readonly record: QualificationRecord;
523
+ /** The children's stderr written beside the record, one file each run that printed anything. */
524
+ readonly stderrPaths: readonly string[];
483
525
  }
484
526
 
485
527
  /**
@@ -492,33 +534,46 @@ export function writeQualification(
492
534
  probeBytes: Uint8Array,
493
535
  judgment: Judgment,
494
536
  notMeasured: readonly NotMeasured[] = [],
537
+ stderr: readonly Uint8Array[] = [],
495
538
  ): WrittenQualification {
496
539
  mkdirSync(dir, { recursive: true });
540
+ // the children's own words first, as the probe copy cites them: a crash's reason is kept, not only its size
541
+ const stderrPaths = stderr
542
+ .filter((s) => s.length > 0)
543
+ .map((s) => {
544
+ const path = join(dir, stderrCopyFileName(judgment.output, sha256Hex(s)));
545
+ writeFileWhole(path, s);
546
+ return path;
547
+ });
497
548
  const probePath = join(dir, probeCopyFileName(judgment.output));
498
549
  writeFileWhole(probePath, probeBytes);
499
550
  const copy = readFileSync(probePath);
500
551
  const record = buildQualificationRecord(judgment, { file: probeCopyFileName(judgment.output), sha256: sha256Hex(copy), bytes: copy.length }, notMeasured);
501
552
  const recordPath = join(dir, recordFileName(judgment.output));
502
553
  writeFileWhole(recordPath, `${JSON.stringify(record, null, 2)}\n`);
503
- return { recordPath, probePath, record };
554
+ return { recordPath, probePath, record, stderrPaths };
504
555
  }
505
556
 
506
557
  /**
507
558
  * Runs that name the same binary and offer share one record file (a CLAP judged with and without the extra extensions
508
559
  * offered): their verdicts become one record over the first run's probe output, so the second is never written over the first.
509
560
  */
510
- export function mergeRunsByRecord<T extends { readonly bytes: Uint8Array; readonly judgment: Judgment }>(runs: readonly T[]): { bytes: Uint8Array; judgment: Judgment }[] {
511
- const byName = new Map<string, { bytes: Uint8Array; judgment: Judgment }>();
561
+ export function mergeRunsByRecord<T extends { readonly bytes: Uint8Array; readonly judgment: Judgment; readonly stderr?: readonly Uint8Array[] }>(
562
+ runs: readonly T[],
563
+ ): { bytes: Uint8Array; judgment: Judgment; stderr: readonly Uint8Array[] }[] {
564
+ // the stderr kept is the first run's, as its probe output is the copy that cites it
565
+ const byName = new Map<string, { bytes: Uint8Array; judgment: Judgment; stderr: readonly Uint8Array[] }>();
512
566
  for (const r of runs) {
513
567
  const name = qualificationBaseName(r.judgment.output);
514
568
  const first = byName.get(name);
515
569
  byName.set(
516
570
  name,
517
571
  first === undefined
518
- ? r
572
+ ? { bytes: r.bytes, judgment: r.judgment, stderr: r.stderr ?? [] }
519
573
  : {
520
574
  bytes: first.bytes,
521
575
  judgment: { ...first.judgment, hosts: [...first.judgment.hosts, ...r.judgment.hosts], documents: [...first.judgment.documents, ...r.judgment.documents] },
576
+ stderr: first.stderr,
522
577
  },
523
578
  );
524
579
  }
@@ -107,6 +107,10 @@ export const LV2_HOST_PROBE_TOOL_ENV = "OMX_LV2_HOST_PROBE";
107
107
  export const ISOLATED_SIGNAL_TOOL_ENV = "OMX_ISOLATED_SIGNAL";
108
108
  /** `native/jack-peak.c`, the level of an instrument under a held note in the adapter run (`tools/mod-host-admit.mjs`). */
109
109
  export const JACK_PEAK_TOOL_ENV = "OMX_JACK_PEAK";
110
+ /** `native/rt-watch.c`, the interposer's window put around a JACK host's process callback: the host comparison's RT watch. */
111
+ export const RT_WATCH_ENV = "OMX_RT_WATCH";
112
+ /** `native/jack-compare.c`, the host comparison's ports, latency and stimulus on the JACK graph. */
113
+ export const JACK_COMPARE_TOOL_ENV = "OMX_JACK_COMPARE";
110
114
 
111
115
  /** The file names of the native tools, in every place they are looked for. */
112
116
  export const CLAP_QUALIFY_TOOL_NAME = "clap-qualify";
@@ -115,6 +119,8 @@ export const LV2_MEASURE_TOOL_NAME = "lv2_measure";
115
119
  export const RT_INTERPOSE_NAME = "rt_interpose.so";
116
120
  export const LV2_HOST_PROBE_TOOL_NAME = "lv2_host_probe";
117
121
  export const JACK_PEAK_TOOL_NAME = "jack_peak";
122
+ export const RT_WATCH_NAME = "rt_watch.so";
123
+ export const JACK_COMPARE_TOOL_NAME = "jack_compare";
118
124
 
119
125
  /**
120
126
  * The ONE list of the native tools the packages install, each with the setting that names it.
@@ -127,6 +133,8 @@ export const NATIVE_TOOLS: readonly { readonly name: string; readonly env: strin
127
133
  { name: LV2_HOST_PROBE_TOOL_NAME, env: LV2_HOST_PROBE_TOOL_ENV },
128
134
  { name: ISOLATED_SIGNAL_TOOL_NAME, env: ISOLATED_SIGNAL_TOOL_ENV },
129
135
  { name: JACK_PEAK_TOOL_NAME, env: JACK_PEAK_TOOL_ENV },
136
+ { name: RT_WATCH_NAME, env: RT_WATCH_ENV },
137
+ { name: JACK_COMPARE_TOOL_NAME, env: JACK_COMPARE_TOOL_ENV },
130
138
  ];
131
139
 
132
140
  /**