@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
@@ -19,6 +19,7 @@ import { basename, join } from "node:path";
19
19
 
20
20
  import { ADAPTER_HOST, ADAPTER_SILENT_CODE, SPAWNED_ADMISSIONS, admitUnderAdapter, type AdmissionRuns, type HostAdmission } from "./host-admission.js";
21
21
  import { HOSTD_ENV } from "./hostd-pin-files.js";
22
+ import { freeDeadJackSlots } from "./jack-registry.js";
22
23
  import { cellKey, type GridCell } from "./measurement-grid.js";
23
24
  import { defaultAddressSpaceLimit } from "./direct-limit.js";
24
25
  import { PROBE_OK } from "./probe-output.js";
@@ -36,7 +37,9 @@ export const NO_PRIVATE_SERVER = `${ADAPTER_HOST}.no-private-server`;
36
37
  const RECORD_SUFFIX = ".qualification.json";
37
38
  const SERVER_WAIT_MS = 5000;
38
39
  const SERVER_SETTLE_MS = 300;
39
- const SERVER_STOP_MS = 2000;
40
+ // a TERM is waited for this long: jackd on a Pi can take seconds to end, and a jackd that is KILLed keeps its row in
41
+ // JACK's server table for good (jack-registry.ts)
42
+ const SERVER_STOP_MS = 10000;
40
43
 
41
44
  /** What a program answers to `--version`: its last word on the line that says `version`. */
42
45
  export function parseVersion(text: string): string | undefined {
@@ -78,14 +81,18 @@ const sleepSync = (ms: number): void => {
78
81
  /**
79
82
  * jackd under a shell that waits for it. A pass is synchronous, so this process cannot reap a child; an unreaped jackd
80
83
  * that had a client stays in JACK's server table as alive, and the next start under its name reads "server already active".
81
- * The shell outlives jackd whatever happens: a TERM reaches jackd once, and HUP is how a stop that gives up kills jackd alone.
82
- * Killed with its group, the shell could not reap it.
84
+ * The shell outlives jackd whatever happens: a TERM to the shell is passed to jackd's own pid, and HUP is how a stop that
85
+ * gives up kills jackd alone. Killed with its group, the shell could not reap it. A TERM to the group does not do: a jackd
86
+ * that is not its group's leader keeps running after it (measured on a Raspberry Pi 5 and on CI), and the stop's KILL then
87
+ * left its row in JACK's registry.
83
88
  */
84
- const REAPING_JACKD = `trap : TERM; trap 'kill -KILL $p 2>/dev/null' HUP; jackd "$@" & p=$!; while kill -0 $p 2>/dev/null; do wait $p; done`;
89
+ const REAPING_JACKD = `trap 'kill -TERM $p 2>/dev/null' TERM; trap 'kill -KILL $p 2>/dev/null' HUP; jackd "$@" & p=$!; while kill -0 $p 2>/dev/null; do wait $p; done`;
85
90
 
86
91
  /** `jackd` on a name of its own and the dummy driver, at the cell's rate and quantum; `undefined` when none came up. */
87
92
  export const startPrivateJack: JackStarter = (cell) => {
88
93
  const name = `omx-qualify-${String(process.pid)}-${String(cell.quantum)}`;
94
+ // the rows of servers of ours that were killed, freed before this one asks for a row
95
+ freeDeadJackSlots();
89
96
  let child: ChildProcess;
90
97
  let ended = false;
91
98
  try {
@@ -110,7 +117,11 @@ export const startPrivateJack: JackStarter = (cell) => {
110
117
  }
111
118
  };
112
119
  const stop = (): void => {
113
- signal("SIGTERM");
120
+ try {
121
+ if (child.pid !== undefined) process.kill(child.pid, "SIGTERM");
122
+ } catch {
123
+ // already gone
124
+ }
114
125
  const until = Date.now() + SERVER_STOP_MS;
115
126
  while (running() && Date.now() < until) sleepSync(20);
116
127
  if (running()) {
@@ -124,6 +135,8 @@ export const startPrivateJack: JackStarter = (cell) => {
124
135
  }
125
136
  signal("SIGKILL");
126
137
  for (const d of serverDirs(name)) rmSync(d, { recursive: true, force: true });
138
+ // a jackd that had to be KILLed left its row: it is freed now, not by the next start
139
+ freeDeadJackSlots();
127
140
  };
128
141
  const until = Date.now() + SERVER_WAIT_MS;
129
142
  while (Date.now() < until) {
@@ -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
  /**
@@ -18,9 +18,10 @@ import { basename, join } from "node:path";
18
18
 
19
19
  import { ADAPTER_PASS_CELLS, startPrivateJack, type JackStarter } from "./qualify-adapter-pass.js";
20
20
  import type { GridCell } from "./measurement-grid.js";
21
- import type { PinEntry } from "./pins-file.js";
21
+ import { clapPinUri, type PinEntry } from "./pins-file.js";
22
22
  import { PROBE_OK, readProbeOutput } from "./probe-output.js";
23
- import type { IsolatedFigures, QualificationRecord, RunBlock, RunStep } from "./qualification-record.js";
23
+ import { HOSTING_CODE } from "./hosting-codes.js";
24
+ import type { IsolatedFigures, QualificationRecord, RecordVerdict, RunBlock, RunStep } from "./qualification-record.js";
24
25
  import { ISOLATED_SIGNAL_TOOL_ENV, ISOLATED_SIGNAL_TOOL_NAME } from "./qualify-declarations.js";
25
26
  import { locateNativeTool, toolsDir } from "./tool-paths.js";
26
27
  import { writeFileWhole } from "./whole-file.js";
@@ -97,9 +98,34 @@ function figuresOf(run: IsolatedRunFacts): IsolatedFigures | undefined {
97
98
  };
98
99
  }
99
100
 
100
- /** Whether a record's outcome calls for the isolated step: rated `conditional` by some host, or a probe that crashed or hung. */
101
+ /**
102
+ * A verdict whose only gap is memory nobody measured: it reads `unknown` and every reason that is not `suitable` is
103
+ * `hosting.memory.unmeasured`. Nothing says the plugin is unfit, so it is hosted in a worker of its own until its
104
+ * footprint is measured, and the isolated step is what shows it running alone.
105
+ */
106
+ export function memoryGapOnly(v: Pick<RecordVerdict, "measured" | "rating" | "reasons">): boolean {
107
+ if (!v.measured || v.rating !== "unknown") return false;
108
+ const open = v.reasons.filter((r) => r.rating !== "suitable");
109
+ return open.length > 0 && open.every((r) => r.code === HOSTING_CODE.memoryUnmeasured);
110
+ }
111
+
112
+ /** Whether a record's outcome calls for the isolated step: rated `conditional` by some host, a memory gap and nothing else, or a probe that crashed or hung. */
101
113
  export function owesIsolated(record: QualificationRecord): boolean {
102
- return PROBE_FAILED_HARD.has(record.probe.outcome) || record.verdicts.some((v) => v.measured && v.rating === "conditional");
114
+ return PROBE_FAILED_HARD.has(record.probe.outcome) || record.verdicts.some((v) => v.measured && (v.rating === "conditional" || memoryGapOnly(v)));
115
+ }
116
+
117
+ /**
118
+ * The pin a record can give back to a plugin whose state lost it: the bytes the probe pinned, and the word of a decided
119
+ * isolated step (`isolated` or `refused`). `undefined` when the record holds no whole pin or the step decided nothing.
120
+ */
121
+ export function pinFromRecord(record: QualificationRecord): PinEntry | undefined {
122
+ const step = record.run?.steps.find((s) => s.step === RUN_STEP.isolated);
123
+ if (step === undefined || step.result === ISOLATED_RESULT.notRun) return undefined;
124
+ const { files, layout } = record.binary;
125
+ if (files === undefined || files.length === 0 || layout === undefined) return undefined;
126
+ const sorted = [...files].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
127
+ const uri = record.plugin.format === "clap" ? clapPinUri(record.binary.path, record.plugin.id) : record.plugin.id;
128
+ return pinAfterStep({ uri, integrity: { files: sorted, layout } }, step);
103
129
  }
104
130
 
105
131
  /** Whether the isolated step saw this plugin running alone. A record's word alone never says so. */
@@ -180,7 +206,7 @@ export function pinAfterStep(pin: PinEntry | undefined, step: RunStep): PinEntry
180
206
  }
181
207
 
182
208
  /** The run id of a record from before the steps: its probe output's, read from the copy beside it. */
183
- function runIdOf(record: QualificationRecord, dir: string): string | undefined {
209
+ export function runIdOf(record: QualificationRecord, dir: string): string | undefined {
184
210
  if (record.run !== undefined) return record.run.id;
185
211
  if (record.rejudged !== undefined) return record.rejudged.figures.runId;
186
212
  try {
@@ -0,0 +1,318 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ /**
4
+ * The pool step: the one place that grants a plugin the CLAP thread pool. omx-clap-host sizes a pool of helper threads
5
+ * from its worker's cpu set, and plugin-hostd starts a worker with it only for a plugin whose pin says `pool=cores`
6
+ * (plugin-db's `PLUGIN_DB_PIN_POOLS`: "measured real-time safe and faster at the quantum"). That word is the plugin's
7
+ * qualification to give, so nothing writes it but this step.
8
+ *
9
+ * A plugin that does not offer `clap.thread-pool` is never asked: its record says `not-asked` and its pin has no pool
10
+ * word, so it runs serial. A plugin that does is first run by the prober with real helper threads under the interposer
11
+ * (`clap-qualify --pool`, which counts what the plugin's tasks do on a thread that is not the audio thread); one that
12
+ * allocates, locks or makes a system call there is `off`. Then it is run twice in a worker of its own on a private JACK
13
+ * server at the host's rate and quantum, with the same notes held, once under `pool=off` and once under `pool=cores`
14
+ * (`tools/pool-step.mjs`), each mode twice in turn and judged by its best window, because what else runs on the machine can
15
+ * only make a window worse. The pool is granted when, with it on,
16
+ *
17
+ * - its tasks, run on the helpers under the interposer, allocated nothing, took no lock and made no system call,
18
+ * - the worker really started with the pool (a daemon that does not apply the word decides nothing),
19
+ * - the output is finite and not silent, and the worker lived through the window,
20
+ * - the window took no more xruns than the same window without the pool, and
21
+ * - the server's DSP load, which is the share of the cycle the plugin's block takes, fell to at most
22
+ * {@link POOL_GAIN} of the load without the pool and by at least {@link POOL_MIN_GAIN} points of the cycle (a plugin
23
+ * with next to nothing to do has too little to run in parallel for a ratio to mean anything).
24
+ *
25
+ * A plugin that is measured and fails any of these gets `pool=off`: the word says it was looked at. A machine that could
26
+ * not run the step decides nothing, and the sync tries again.
27
+ */
28
+ import { spawnSync } from "node:child_process";
29
+ import { existsSync } from "node:fs";
30
+ import { availableParallelism } from "node:os";
31
+ import { join } from "node:path";
32
+
33
+ import type { GridCell } from "./measurement-grid.js";
34
+ import type { PinEntry, PinPool } from "./pins-file.js";
35
+ import type { PoolFigures, QualificationRecord, RunBlock, RunStep } from "./qualification-record.js";
36
+ import { ADMIT_STEP_MS_ENV, CLAP_QUALIFY_TOOL_NAME, ISOLATED_SIGNAL_TOOL_ENV, ISOLATED_SIGNAL_TOOL_NAME, RT_INTERPOSE_NAME } from "./qualify-declarations.js";
37
+ import { ISOLATED_RESULT, RUN_STEP, baseSteps, pinFromRecord, runIdOf } from "./qualify-isolated.js";
38
+ import { startPrivateJack, type JackStarter } from "./qualify-adapter-pass.js";
39
+ import { locateNativeTool, toolsDir } from "./tool-paths.js";
40
+ import { writeFileWhole } from "./whole-file.js";
41
+
42
+ /** The name of the step in a record's run. It is not one of `RUN_STEP`: a record is complete without it (a plugin that does not ask for the pool, or a machine that cannot run the step, has none). */
43
+ export const POOL_STEP = "pool";
44
+
45
+ /** The server's DSP load with the pool may be at most this fraction of the load without it. */
46
+ export const POOL_GAIN = 0.85;
47
+
48
+ /**
49
+ * The load must also fall by at least this many points of the cycle. The server's load has a floor of its own (the signal
50
+ * client, the ports, the worker's routing), so a plugin with next to nothing to do can fall by a good ratio of a small
51
+ * difference, and a pool for it would cost the cores it holds.
52
+ */
53
+ export const POOL_MIN_GAIN = 3;
54
+
55
+ /**
56
+ * The pool's helpers must have been on a cpu this many nanoseconds in the busiest window with the pool (20 ms of a 3 s
57
+ * window, across all of them): a worker that holds the pool and whose plugin asks for tasks every block keeps them
58
+ * busy for a good part of it, and a few microseconds is a start-up, not a pool at work.
59
+ */
60
+ export const POOL_HELPERS_IDLE_NS = 20_000_000;
61
+
62
+ export const POOL_RESULT = { cores: "cores", off: "off", notAsked: "not-asked", notRun: "not-run" } as const;
63
+
64
+ /** Why the step decided what it did, or why it could not run. */
65
+ export const POOL_CODE = {
66
+ noHost: "pool.no-host",
67
+ noServer: "pool.no-private-server",
68
+ notAdded: "pool.not-added",
69
+ windowFailed: "pool.window-failed",
70
+ unwatched: "pool.not-watched",
71
+ notRtSafe: "pool.not-rt-safe",
72
+ notApplied: "pool.not-applied",
73
+ crashed: "pool.crashed-with-pool",
74
+ unsafeOutput: "pool.unsafe-output",
75
+ xruns: "pool.more-xruns",
76
+ notFaster: "pool.not-faster",
77
+ helpersIdle: "pool.helpers-idle",
78
+ } as const;
79
+
80
+ /** What one window saw, with the pool as the word set it. */
81
+ export interface PoolWindowFacts {
82
+ readonly problem?: string;
83
+ readonly alive?: boolean;
84
+ readonly workers?: number;
85
+ /** hostd's `worker_list` field for the worker: `on` for a worker started with the pool. */
86
+ readonly poolOfWorker?: string;
87
+ readonly xruns?: number;
88
+ readonly finite?: boolean;
89
+ readonly silent?: boolean;
90
+ readonly dspLoad?: number;
91
+ /** Nanoseconds the worker's pool helpers were on a cpu in the window (the busiest of the windows taken): little means the worker held the pool and its helpers ran next to nothing. */
92
+ readonly helperBusyNs?: number;
93
+ }
94
+
95
+ /** What the plugin did on the helper threads of a real pool under the interposer. */
96
+ export interface PoolRtFacts {
97
+ readonly helpers: number;
98
+ /** The tasks the helpers ran; none means the pool was not exercised, and nothing was seen of it. */
99
+ readonly tasksOnHelpers: number;
100
+ readonly allocations: number;
101
+ readonly locks: number;
102
+ readonly syscalls: number;
103
+ }
104
+
105
+ /** What one run of the step saw. A run that never reached the plugin says `started: false`, and `problem`. */
106
+ export interface PoolRunFacts {
107
+ readonly started: boolean;
108
+ readonly problem?: string;
109
+ /** Whether the plugin offers clap.thread-pool. */
110
+ readonly asks?: boolean;
111
+ /** The plugin's tasks on helper threads, counted by the interposer (the prober's `--pool`); absent when it could not be watched. */
112
+ readonly rt?: PoolRtFacts;
113
+ readonly added?: boolean;
114
+ readonly off?: PoolWindowFacts;
115
+ readonly on?: PoolWindowFacts;
116
+ /** The worker under `pool=cores` was started with the pool and the one under `pool=off` was not. */
117
+ readonly poolApplied?: boolean;
118
+ }
119
+
120
+ export type PoolRunner = (uri: string, cell: GridCell, env: Readonly<Record<string, string | undefined>>) => PoolRunFacts;
121
+
122
+ function figuresOf(run: PoolRunFacts): PoolFigures | undefined {
123
+ const { off, on, rt } = run;
124
+ const watched = rt === undefined ? {} : { allocationsOn: rt.allocations, locksOn: rt.locks, syscallsOn: rt.syscalls, tasksOnHelpers: rt.tasksOnHelpers };
125
+ const timed = off?.dspLoad === undefined || on?.dspLoad === undefined ? {} : { dspLoadOff: off.dspLoad, dspLoadOn: on.dspLoad, xrunsOff: off.xruns ?? 0, xrunsOn: on.xruns ?? 0 };
126
+ return Object.keys(watched).length === 0 && Object.keys(timed).length === 0 ? undefined : { ...watched, ...timed };
127
+ }
128
+
129
+ /** The decision table of the step, with the figures it took. Pure. */
130
+ export function judgePoolRun(run: PoolRunFacts): RunStep {
131
+ const step = (result: string, code?: string, detail?: string): RunStep => {
132
+ const pool = figuresOf(run);
133
+ return { step: POOL_STEP, result, ...(code === undefined ? {} : { code }), ...(detail === undefined ? {} : { detail }), ...(pool === undefined ? {} : { pool }) };
134
+ };
135
+ if (!run.started) return step(POOL_RESULT.notRun, POOL_CODE.noHost, run.problem);
136
+ if (run.asks === false) return step(POOL_RESULT.notAsked);
137
+ // the plugin on helper threads comes first: a plugin that allocates or locks there is refused the pool whatever it gains,
138
+ // and one nobody could watch there decides nothing
139
+ const { rt } = run;
140
+ if (rt === undefined || rt.tasksOnHelpers < 1) return step(POOL_RESULT.notRun, POOL_CODE.unwatched, run.problem ?? "the plugin's tasks were not seen on helper threads under the interposer");
141
+ if (rt.allocations + rt.locks + rt.syscalls > 0) {
142
+ return step(POOL_RESULT.off, POOL_CODE.notRtSafe, `${String(rt.allocations)} allocations, ${String(rt.locks)} locks and ${String(rt.syscalls)} system calls while its tasks ran on ${String(rt.helpers)} helper threads`);
143
+ }
144
+ if (run.added !== true) return step(POOL_RESULT.notRun, POOL_CODE.notAdded, run.problem);
145
+ const { off, on } = run;
146
+ // the window without the pool is the reference: a plugin that does not even run alone says nothing about the pool
147
+ if (off === undefined || off.problem !== undefined || off.alive !== true || off.finite !== true) return step(POOL_RESULT.notRun, POOL_CODE.windowFailed, off?.problem ?? "the window without the pool did not run");
148
+ if (on === undefined) return step(POOL_RESULT.notRun, POOL_CODE.windowFailed, "the window with the pool did not run");
149
+ if (run.poolApplied !== true) return step(POOL_RESULT.notRun, POOL_CODE.notApplied, "the daemon did not start the worker with the thread pool");
150
+ if (on.alive !== true) return step(POOL_RESULT.off, POOL_CODE.crashed, on.problem);
151
+ if (on.problem !== undefined) return step(POOL_RESULT.notRun, POOL_CODE.windowFailed, on.problem);
152
+ if (on.finite !== true || on.silent !== false) return step(POOL_RESULT.off, POOL_CODE.unsafeOutput);
153
+ // the worker held the pool and its helpers ran nothing: the plugin's requests never reached them, so the window with the
154
+ // pool measured the window without it again, and no gain was seen because there was nothing to see
155
+ if (on.helperBusyNs !== undefined && on.helperBusyNs < POOL_HELPERS_IDLE_NS) return step(POOL_RESULT.off, POOL_CODE.helpersIdle, "the worker's pool helpers ran no task in the window with the pool");
156
+ if ((on.xruns ?? 0) > (off.xruns ?? 0)) return step(POOL_RESULT.off, POOL_CODE.xruns);
157
+ if (off.dspLoad === undefined || on.dspLoad === undefined) return step(POOL_RESULT.notRun, POOL_CODE.windowFailed, "the server's load was not read");
158
+ if (on.dspLoad > off.dspLoad * POOL_GAIN || off.dspLoad - on.dspLoad < POOL_MIN_GAIN) return step(POOL_RESULT.off, POOL_CODE.notFaster);
159
+ return step(POOL_RESULT.cores);
160
+ }
161
+
162
+ /** The pool word a step grants, or `undefined` for a step that decided nothing or granted none. */
163
+ export function poolWordOf(step: RunStep | undefined): PinPool | undefined {
164
+ if (step?.step !== POOL_STEP) return undefined;
165
+ return step.result === POOL_RESULT.cores ? "cores" : step.result === POOL_RESULT.off ? "off" : undefined;
166
+ }
167
+
168
+ /** The pool step of a run, if it has one. */
169
+ export function poolStepOf(run: QualificationRecord["run"]): RunStep | undefined {
170
+ return run?.steps.find((s) => s.step === POOL_STEP);
171
+ }
172
+
173
+ /** The pin once the step has spoken: its pool word follows the step, and a step that decided nothing leaves none. A refused pin carries none. Pure. */
174
+ export function pinAfterPool(pin: PinEntry | undefined, step: RunStep | undefined): PinEntry | undefined {
175
+ if (pin === undefined) return undefined;
176
+ const { pool: _had, ...rest } = pin;
177
+ const word = poolWordOf(step);
178
+ return word === undefined || pin.verdict === "refused" ? rest : { ...rest, pool: word };
179
+ }
180
+
181
+ /** Whether a step ended with a decision: `not-run` is the machine's, and the sync asks again. */
182
+ export const poolDecided = (s: RunStep): boolean => s.step === POOL_STEP && s.result !== POOL_RESULT.notRun;
183
+
184
+ /**
185
+ * Whether a record owes the pool step: a CLAP plugin judged in a pass with steps, not refused, with no decided pool step.
186
+ * A record from before the steps owes the isolated step first, which gives it a run; the pool is for a plugin that runs,
187
+ * and a refused one never is.
188
+ */
189
+ export function owesPool(record: QualificationRecord): boolean {
190
+ if (record.plugin.format !== "clap" || record.run === undefined) return false;
191
+ const steps = record.run.steps;
192
+ if (steps.some(poolDecided)) return false;
193
+ if (steps.some((s) => s.step === RUN_STEP.isolated && s.result === ISOLATED_RESULT.refused)) return false;
194
+ return record.verdicts.some((v) => v.measured && v.rating !== "unsuitable");
195
+ }
196
+
197
+ /** How long each window plays, and what the step may take beyond the two: the host's start and its answers. */
198
+ const POOL_WINDOW_MS = 3000;
199
+ const POOL_ROUNDS = 2;
200
+ const POOL_STEP_SLACK_MS = 120000;
201
+
202
+ /** Whether the plugin of a `clap:<path>#<id>` uri offers clap.thread-pool, by the prober's own `--asks-thread-pool`; `undefined` when it could not be asked. */
203
+ export function asksForPool(prober: string, uri: string, env: Readonly<Record<string, string | undefined>>): boolean | undefined {
204
+ const m = /^clap:(.*)#([^#]*)$/s.exec(uri);
205
+ if (m === null) return undefined;
206
+ const r = spawnSync(prober, ["--asks-thread-pool", m[1]!, m[2]!], { encoding: "utf8", timeout: 60000, killSignal: "SIGKILL", env: { ...env } });
207
+ if (r.status !== 0) return undefined;
208
+ try {
209
+ return (JSON.parse(r.stdout) as { threadPool?: unknown }).threadPool === true;
210
+ } catch {
211
+ return undefined;
212
+ }
213
+ }
214
+
215
+ /** The most helper threads the prober's pool makes (`POOL_HELPERS_MAX` in native/clap-qualify.c). */
216
+ const POOL_HELPERS_MAX = 7;
217
+
218
+ /** How many blocks the prober's sweeps run: the sync's own, so a plugin that needs a few blocks to settle is seen settled. */
219
+ const WATCH_BLOCKS = 512;
220
+
221
+ /**
222
+ * What the plugin does on helper threads: the prober run with a real pool of one helper per core of this process's set but
223
+ * one (at most seven), under the interposer, which counts what any thread does while a sweep runs. The pool must have
224
+ * been used: a run whose tasks all ran on the calling thread saw nothing of the helpers.
225
+ */
226
+ export function watchOnHelpers(prober: string, interposer: string, uri: string, cell: GridCell, env: Readonly<Record<string, string | undefined>>): { readonly rt: PoolRtFacts } | { readonly problem: string } {
227
+ const m = /^clap:(.*)#([^#]*)$/s.exec(uri);
228
+ if (m === null) return { problem: `${uri} is no clap uri` };
229
+ const helpers = Math.min(POOL_HELPERS_MAX, availableParallelism() - 1);
230
+ if (helpers < 1) return { problem: "this machine has one core, and a pool of one core is no pool" };
231
+ const preload = [interposer, env["LD_PRELOAD"]].filter((x) => x !== undefined && x !== "").join(" ");
232
+ const r = spawnSync(prober, ["--rates", String(cell.rate), "--block", String(cell.quantum), "--blocks", String(WATCH_BLOCKS), "--pool", String(helpers), m[1]!, m[2]!], {
233
+ encoding: "utf8",
234
+ timeout: 120000,
235
+ killSignal: "SIGKILL",
236
+ env: { ...env, LD_PRELOAD: preload },
237
+ });
238
+ if (r.error !== undefined || r.status !== 0) return { problem: `${CLAP_QUALIFY_TOOL_NAME} --pool: ${r.error?.message ?? `exit ${String(r.status)}`}` };
239
+ const line = r.stdout.split("\n").filter((l) => l.startsWith("{")).pop();
240
+ if (line === undefined) return { problem: `${CLAP_QUALIFY_TOOL_NAME} --pool printed no line` };
241
+ try {
242
+ const doc = JSON.parse(line) as { rtSafety?: { interposer?: boolean; allocationsInRun?: number; locksInRun?: number; syscallsInRun?: number }; noteSweep?: { interposer?: boolean; allocationsInRun?: number; locksInRun?: number; syscallsInRun?: number }; threadPool?: { tasksOnHelpers?: number } };
243
+ const sweeps = [doc.rtSafety, doc.noteSweep].filter((x) => x?.interposer === true);
244
+ if (doc.rtSafety?.interposer !== true) return { problem: "the interposer did not load in the prober" };
245
+ const sum = (key: "allocationsInRun" | "locksInRun" | "syscallsInRun"): number => sweeps.reduce((n, x) => n + (x?.[key] ?? 0), 0);
246
+ return { rt: { helpers, tasksOnHelpers: doc.threadPool?.tasksOnHelpers ?? 0, allocations: sum("allocationsInRun"), locks: sum("locksInRun"), syscalls: sum("syscallsInRun") } };
247
+ } catch {
248
+ return { problem: `${CLAP_QUALIFY_TOOL_NAME} --pool printed no JSON` };
249
+ }
250
+ }
251
+
252
+ /** The step as it runs on a device: a private server at the cell, and `tools/pool-step.mjs` under it. */
253
+ export function spawnedPoolRunner(jack: JackStarter = startPrivateJack): PoolRunner {
254
+ return (uri, cell, env) => {
255
+ const prober = locateNativeTool(CLAP_QUALIFY_TOOL_NAME, env, existsSync).path;
256
+ if (prober === undefined) return { started: false, problem: `no ${CLAP_QUALIFY_TOOL_NAME}` };
257
+ // a plugin that does not offer the pool is not worth a JACK server and a daemon: ask the plugin first
258
+ const asks = asksForPool(prober, uri, env);
259
+ if (asks === undefined) return { started: false, problem: `${uri} could not be asked for clap.thread-pool` };
260
+ if (!asks) return { started: true, asks: false };
261
+ // then what it does on helper threads, which needs no server: a plugin that allocates there is refused without one
262
+ const interposer = locateNativeTool(RT_INTERPOSE_NAME, env, existsSync).path;
263
+ if (interposer === undefined) return { started: true, asks: true, problem: `no ${RT_INTERPOSE_NAME}` };
264
+ const watched = watchOnHelpers(prober, interposer, uri, cell, env);
265
+ if (!("rt" in watched)) return { started: true, asks: true, problem: watched.problem };
266
+ if (watched.rt.tasksOnHelpers < 1 || watched.rt.allocations + watched.rt.locks + watched.rt.syscalls > 0) return { started: true, asks: true, rt: watched.rt };
267
+ const signal = locateNativeTool(ISOLATED_SIGNAL_TOOL_NAME, env, existsSync).path;
268
+ if (signal === undefined) return { started: false, problem: `no ${ISOLATED_SIGNAL_TOOL_NAME}` };
269
+ const server = jack(cell);
270
+ if (server === undefined) return { started: false, problem: `${POOL_CODE.noServer} at ${String(cell.rate)}/${String(cell.quantum)}` };
271
+ try {
272
+ const r = spawnSync(process.execPath, [join(toolsDir(), "pool-step.mjs"), uri, String(POOL_WINDOW_MS)], {
273
+ encoding: "utf8",
274
+ timeout: 2 * POOL_ROUNDS * POOL_WINDOW_MS + POOL_STEP_SLACK_MS,
275
+ killSignal: "SIGKILL",
276
+ env: { ...env, JACK_DEFAULT_SERVER: server.name, [ISOLATED_SIGNAL_TOOL_ENV]: signal },
277
+ });
278
+ if (r.error !== undefined || r.status !== 0) return { started: false, problem: `pool-step: ${r.error?.message ?? `exit ${String(r.status)}`}: ${(r.stderr ?? "").trim()}` };
279
+ return { ...(JSON.parse(r.stdout) as PoolRunFacts), asks: true, rt: watched.rt };
280
+ } finally {
281
+ server.stop();
282
+ }
283
+ };
284
+ }
285
+
286
+ /** The step's environment: each of its commands has the sync's budget. */
287
+ export const poolEnv = (env: Readonly<Record<string, string | undefined>>, budgetS: number): Readonly<Record<string, string | undefined>> => ({ ...env, [ADMIT_STEP_MS_ENV]: String(budgetS * 1000) });
288
+
289
+ /**
290
+ * The steps of a run with the pool step added or replaced: a step that could not run takes the place of an earlier one
291
+ * that could not, and a decided step is never replaced here.
292
+ */
293
+ export function withPoolStep(steps: readonly RunStep[], step: RunStep): RunStep[] {
294
+ return [...steps.filter((s) => s.step !== POOL_STEP), step];
295
+ }
296
+
297
+ /**
298
+ * The pin a record can give back to a plugin whose state lost it: what {@link pinFromRecord} gives, with the pool word
299
+ * of the record's pool step.
300
+ */
301
+ export function pinFromRecordWithPool(record: QualificationRecord): PinEntry | undefined {
302
+ return pinAfterPool(pinFromRecord(record), poolStepOf(record.run));
303
+ }
304
+
305
+ /**
306
+ * The missing pool step of a record already judged: the run keeps its id and gains the step, and nothing else is
307
+ * measured. `undefined` when it owes none, or has no run id to extend. Writes the record whole.
308
+ */
309
+ export function completePool(path: string, record: QualificationRecord, uri: string, cell: GridCell, runner: PoolRunner, env: Readonly<Record<string, string | undefined>>): { readonly record: QualificationRecord; readonly step: RunStep } | undefined {
310
+ if (!owesPool(record)) return undefined;
311
+ const id = runIdOf(record, join(path, ".."));
312
+ if (id === undefined) return undefined;
313
+ const step = judgePoolRun(runner(uri, cell, env));
314
+ const run: RunBlock = { id, steps: withPoolStep(record.run?.steps ?? baseSteps(record), step) };
315
+ const next = { ...record, run };
316
+ writeFileWhole(path, `${JSON.stringify(next, null, 2)}\n`);
317
+ return { record: next, step };
318
+ }
@@ -49,6 +49,7 @@ import {
49
49
  type RunBlock,
50
50
  } from "./qualification-record.js";
51
51
  import { ISOLATED_RESULT, RUN_STEP, pinAfterStep, provenAlone } from "./qualify-isolated.js";
52
+ import { pinAfterPool, poolStepOf } from "./qualify-pool.js";
52
53
  import { PI_CELLS, PROBE_MEMORY_ONLY_ENV, PROBE_NOTES_ENV } from "./qualify-declarations.js";
53
54
  import { SYNC_STATE_SCHEMA, digestFiles, recordStem, type SyncContext, type SyncEntry, type SyncPlugin, type SyncState } from "./qualify-sync.js";
54
55
  import type { SuitabilityRating } from "./rating.js";
@@ -270,6 +271,13 @@ const nameOf = (record: QualificationRecord): string => record.plugin.id;
270
271
 
271
272
  /** A state plugin with the verdict its record now holds for the host: the pin's files stay as they are, only its word follows. */
272
273
  function pluginWithVerdict(plugin: SyncPlugin, v: RecordVerdict, run?: RunBlock): SyncPlugin {
274
+ // the pool word is a figure of the record's pool step, not a rating: a re-judge keeps it
275
+ const judged = pluginWithVerdictOnly(plugin, v, run);
276
+ const step = poolStepOf(run);
277
+ return judged.pin === undefined || step === undefined ? judged : { ...judged, pin: pinAfterPool(judged.pin, step)! };
278
+ }
279
+
280
+ function pluginWithVerdictOnly(plugin: SyncPlugin, v: RecordVerdict, run?: RunBlock): SyncPlugin {
273
281
  const { pin, code: _code, ...rest } = plugin;
274
282
  const word = v.measured ? pinVerdictOf(v.rating as SuitabilityRating) : undefined;
275
283
  // a rating does not make a plugin isolated: a run seen alone does, and a re-judge measures nothing. A record from before