@openmixer/plugin-qualify 0.18.1 → 0.18.3

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 (48) hide show
  1. package/README.md +9 -1
  2. package/bin/plugin-qualify-sync.mjs +1 -1
  3. package/dist/clap-qualify.d.ts +3 -1
  4. package/dist/clap-qualify.js +3 -1
  5. package/dist/clap-qualify.js.map +1 -1
  6. package/dist/host-admission.d.ts +19 -0
  7. package/dist/host-admission.js +21 -0
  8. package/dist/host-admission.js.map +1 -1
  9. package/dist/hostd-pin-files.js +3 -1
  10. package/dist/hostd-pin-files.js.map +1 -1
  11. package/dist/measurement/format.d.ts +10 -0
  12. package/dist/measurement/format.js +8 -0
  13. package/dist/measurement/format.js.map +1 -1
  14. package/dist/measurement/probe-json.js +2 -1
  15. package/dist/measurement/probe-json.js.map +1 -1
  16. package/dist/probe-judge.js +2 -2
  17. package/dist/probe-judge.js.map +1 -1
  18. package/dist/qualification-record.d.ts +25 -2
  19. package/dist/qualification-record.js.map +1 -1
  20. package/dist/qualify-adapter-pass.js +21 -3
  21. package/dist/qualify-adapter-pass.js.map +1 -1
  22. package/dist/qualify-declarations.d.ts +5 -0
  23. package/dist/qualify-declarations.js +7 -0
  24. package/dist/qualify-declarations.js.map +1 -1
  25. package/dist/qualify-isolated.d.ts +70 -0
  26. package/dist/qualify-isolated.js +186 -0
  27. package/dist/qualify-isolated.js.map +1 -0
  28. package/dist/qualify-rejudge.d.ts +1 -0
  29. package/dist/qualify-rejudge.js +16 -3
  30. package/dist/qualify-rejudge.js.map +1 -1
  31. package/dist/qualify-sync.d.ts +25 -0
  32. package/dist/qualify-sync.js +104 -11
  33. package/dist/qualify-sync.js.map +1 -1
  34. package/package.json +1 -1
  35. package/src/clap-qualify.ts +5 -1
  36. package/src/host-admission.ts +27 -0
  37. package/src/hostd-pin-files.ts +3 -1
  38. package/src/measurement/format.ts +17 -0
  39. package/src/measurement/probe-json.ts +2 -1
  40. package/src/probe-judge.ts +2 -2
  41. package/src/qualification-record.ts +28 -2
  42. package/src/qualify-adapter-pass.ts +19 -2
  43. package/src/qualify-declarations.ts +7 -0
  44. package/src/qualify-isolated.ts +203 -0
  45. package/src/qualify-rejudge.ts +17 -3
  46. package/src/qualify-sync.ts +107 -11
  47. package/tools/isolated-step.mjs +122 -0
  48. package/tools/mod-host-admit.mjs +78 -2
@@ -104,12 +104,17 @@ export const CLAP_QUALIFY_TOOL_ENV = "OMX_CLAP_QUALIFY";
104
104
  export const LV2_MEASURE_TOOL_ENV = "OMX_LV2_MEASURE";
105
105
  export const RT_INTERPOSE_ENV = "OMX_RT_INTERPOSE";
106
106
  export const LV2_HOST_PROBE_TOOL_ENV = "OMX_LV2_HOST_PROBE";
107
+ export const ISOLATED_SIGNAL_TOOL_ENV = "OMX_ISOLATED_SIGNAL";
108
+ /** `native/jack-peak.c`, the level of an instrument under a held note in the adapter run (`tools/mod-host-admit.mjs`). */
109
+ export const JACK_PEAK_TOOL_ENV = "OMX_JACK_PEAK";
107
110
 
108
111
  /** The file names of the native tools, in every place they are looked for. */
109
112
  export const CLAP_QUALIFY_TOOL_NAME = "clap-qualify";
113
+ export const ISOLATED_SIGNAL_TOOL_NAME = "isolated_signal";
110
114
  export const LV2_MEASURE_TOOL_NAME = "lv2_measure";
111
115
  export const RT_INTERPOSE_NAME = "rt_interpose.so";
112
116
  export const LV2_HOST_PROBE_TOOL_NAME = "lv2_host_probe";
117
+ export const JACK_PEAK_TOOL_NAME = "jack_peak";
113
118
 
114
119
  /**
115
120
  * The ONE list of the native tools the packages install, each with the setting that names it.
@@ -120,6 +125,8 @@ export const NATIVE_TOOLS: readonly { readonly name: string; readonly env: strin
120
125
  { name: LV2_MEASURE_TOOL_NAME, env: LV2_MEASURE_TOOL_ENV },
121
126
  { name: RT_INTERPOSE_NAME, env: RT_INTERPOSE_ENV },
122
127
  { name: LV2_HOST_PROBE_TOOL_NAME, env: LV2_HOST_PROBE_TOOL_ENV },
128
+ { name: ISOLATED_SIGNAL_TOOL_NAME, env: ISOLATED_SIGNAL_TOOL_ENV },
129
+ { name: JACK_PEAK_TOOL_NAME, env: JACK_PEAK_TOOL_ENV },
123
130
  ];
124
131
 
125
132
  /**
@@ -0,0 +1,203 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
3
+ /**
4
+ * The steps of one plugin's pass, and the last of them: `verdict=isolated` means the plugin was seen running alone in a
5
+ * worker of its own (plugin-qualify#249). A plugin the RT-safety step finds unsafe takes the isolated step in the same
6
+ * pass, and so does one that crashed or hung in the probe. The step runs on a private JACK server at the host's rate and
7
+ * quantum, under a plugin-hostd of its own: the plugin is added alone, `worker_list` must show it alone, and a sine plays
8
+ * through it for a window. Only a plugin that was added, stayed alive and gave finite, non-silent output is isolated. A
9
+ * machine that could not run the step decides nothing about the plugin: no verdict word, and the sync tries it again.
10
+ *
11
+ * Every record says which steps judged it. {@link missingSteps} is what the sync refuses a record by; a record from before
12
+ * the steps has no `run` at all, owes the isolated step if its outcome calls for one, and a completion adds it
13
+ * ({@link completeIsolated}), measuring nothing else.
14
+ */
15
+ import { spawnSync } from "node:child_process";
16
+ import { existsSync, readFileSync } from "node:fs";
17
+ import { basename, join } from "node:path";
18
+
19
+ import { ADAPTER_PASS_CELLS, startPrivateJack, type JackStarter } from "./qualify-adapter-pass.js";
20
+ import type { GridCell } from "./measurement-grid.js";
21
+ import type { PinEntry } from "./pins-file.js";
22
+ import { PROBE_OK, readProbeOutput } from "./probe-output.js";
23
+ import type { IsolatedFigures, QualificationRecord, RunBlock, RunStep } from "./qualification-record.js";
24
+ import { ISOLATED_SIGNAL_TOOL_ENV, ISOLATED_SIGNAL_TOOL_NAME } from "./qualify-declarations.js";
25
+ import { locateNativeTool, toolsDir } from "./tool-paths.js";
26
+ import { writeFileWhole } from "./whole-file.js";
27
+
28
+ /** The steps of a pass, in the order they run. */
29
+ export const RUN_STEP = { scan: "scan", describe: "describe", probe: "probe", rtSafety: "rt-safety", isolated: "isolated" } as const;
30
+
31
+ export const ISOLATED_RESULT = { isolated: "isolated", refused: "refused", notRun: "not-run" } as const;
32
+
33
+ /** Why the step refused a plugin, or why it could not run. */
34
+ export const ISOLATED_CODE = {
35
+ crashed: "isolated.crashed",
36
+ notAdded: "isolated.not-added",
37
+ noWorker: "isolated.no-worker",
38
+ notAlone: "isolated.not-alone",
39
+ nonFinite: "isolated.non-finite-output",
40
+ silent: "isolated.silent-output",
41
+ noServer: "isolated.no-private-server",
42
+ noHost: "isolated.no-host",
43
+ } as const;
44
+
45
+ /** The outcomes of a probe that take the isolated step as well: the plugin never reached a verdict in-process. */
46
+ const PROBE_FAILED_HARD = new Set(["probe.crashed", "probe.timeout"]);
47
+
48
+ /** What one run of the step saw. A run that never reached the plugin says `started: false`, and `problem`. */
49
+ export interface IsolatedRunFacts {
50
+ readonly started: boolean;
51
+ readonly problem?: string;
52
+ readonly added?: boolean;
53
+ /** How many workers `worker_list` showed, and how many instances the plugin's holds. */
54
+ readonly workers?: number;
55
+ readonly instancesInWorker?: number;
56
+ readonly alive?: boolean;
57
+ readonly latencyFrames?: number;
58
+ readonly cpuPercent?: number;
59
+ readonly xruns?: number;
60
+ readonly finite?: boolean;
61
+ readonly silent?: boolean;
62
+ }
63
+
64
+ export type IsolatedRunner = (uri: string, cell: GridCell, env: Readonly<Record<string, string | undefined>>) => IsolatedRunFacts;
65
+
66
+ /** The decision table of the step, with the figures it took. Pure. */
67
+ export function judgeIsolatedRun(run: IsolatedRunFacts): RunStep {
68
+ const step = (result: string, code?: string, detail?: string): RunStep => ({
69
+ step: RUN_STEP.isolated,
70
+ result,
71
+ ...(code === undefined ? {} : { code }),
72
+ ...(detail === undefined ? {} : { detail }),
73
+ ...(figuresOf(run) === undefined ? {} : { figures: figuresOf(run)! }),
74
+ });
75
+ if (!run.started) return step(ISOLATED_RESULT.notRun, ISOLATED_CODE.noHost, run.problem);
76
+ if (run.added !== true) return step(ISOLATED_RESULT.refused, ISOLATED_CODE.notAdded, run.problem);
77
+ if (run.alive !== true) return step(ISOLATED_RESULT.refused, ISOLATED_CODE.crashed, run.problem);
78
+ if ((run.workers ?? 0) < 1) return step(ISOLATED_RESULT.refused, ISOLATED_CODE.noWorker);
79
+ if (run.workers !== 1 || run.instancesInWorker !== 1) return step(ISOLATED_RESULT.refused, ISOLATED_CODE.notAlone);
80
+ if (run.finite !== true) return step(ISOLATED_RESULT.refused, ISOLATED_CODE.nonFinite);
81
+ if (run.silent !== false) return step(ISOLATED_RESULT.refused, ISOLATED_CODE.silent);
82
+ return step(ISOLATED_RESULT.isolated);
83
+ }
84
+
85
+ function figuresOf(run: IsolatedRunFacts): IsolatedFigures | undefined {
86
+ if (run.latencyFrames === undefined && run.cpuPercent === undefined && run.xruns === undefined) return undefined;
87
+ return {
88
+ ...(run.latencyFrames === undefined ? {} : { latencyFrames: run.latencyFrames }),
89
+ ...(run.cpuPercent === undefined ? {} : { cpuPercent: run.cpuPercent }),
90
+ ...(run.xruns === undefined ? {} : { xruns: run.xruns }),
91
+ };
92
+ }
93
+
94
+ /** Whether a record's outcome calls for the isolated step: rated `conditional` by some host, or a probe that crashed or hung. */
95
+ export function owesIsolated(record: QualificationRecord): boolean {
96
+ return PROBE_FAILED_HARD.has(record.probe.outcome) || record.verdicts.some((v) => v.measured && v.rating === "conditional");
97
+ }
98
+
99
+ /** Whether the isolated step saw this plugin running alone. A record's word alone never says so. */
100
+ export function provenAlone(record: QualificationRecord): boolean {
101
+ return record.run?.steps.some((s) => s.step === RUN_STEP.isolated && s.result === ISOLATED_RESULT.isolated) === true;
102
+ }
103
+
104
+ /** The steps a record's outcome calls for. */
105
+ export function stepsCalledFor(record: QualificationRecord): readonly string[] {
106
+ return [
107
+ RUN_STEP.scan,
108
+ RUN_STEP.describe,
109
+ RUN_STEP.probe,
110
+ ...(record.probe.outcome === PROBE_OK ? [RUN_STEP.rtSafety] : []),
111
+ ...(owesIsolated(record) ? [RUN_STEP.isolated] : []),
112
+ ];
113
+ }
114
+
115
+ /** The steps a record that carries a `run` lacks, for the sync to refuse it by. A record from before the steps has none to lack. */
116
+ export function missingSteps(record: QualificationRecord): readonly string[] {
117
+ if (record.run === undefined) return [];
118
+ const had = new Set(record.run.steps.map((s) => s.step));
119
+ return stepsCalledFor(record).filter((s) => !had.has(s));
120
+ }
121
+
122
+ /** The steps every record has by the time it is written, from what it says of them. Pure. */
123
+ export function baseSteps(record: QualificationRecord, describeProblem?: string): RunStep[] {
124
+ const measured = record.measurements.grid.some((p) => p.rtSafety !== undefined);
125
+ return [
126
+ { step: RUN_STEP.scan, result: "ok" },
127
+ describeProblem === undefined ? { step: RUN_STEP.describe, result: "ok" } : { step: RUN_STEP.describe, result: "failed", detail: describeProblem },
128
+ { step: RUN_STEP.probe, result: record.probe.outcome },
129
+ ...(record.probe.outcome === PROBE_OK
130
+ ? [{ step: RUN_STEP.rtSafety, result: !measured ? "not-measured" : record.verdicts.some((v) => v.rating === "conditional") ? "unsafe" : "safe" }]
131
+ : []),
132
+ ];
133
+ }
134
+
135
+ /** How long the sine plays, and what a step may take beyond it: the host's start and its answers. */
136
+ const ISOLATED_WINDOW_MS = 3000;
137
+ const ISOLATED_STEP_SLACK_MS = 90000;
138
+
139
+ /** The step as it runs on a device: a private server at the cell, and `tools/isolated-step.mjs` under it. */
140
+ export function spawnedIsolatedRunner(jack: JackStarter = startPrivateJack): IsolatedRunner {
141
+ return (uri, cell, env) => {
142
+ const signal = locateNativeTool(ISOLATED_SIGNAL_TOOL_NAME, env, existsSync).path;
143
+ if (signal === undefined) return { started: false, problem: `no ${ISOLATED_SIGNAL_TOOL_NAME}` };
144
+ const server = jack(cell);
145
+ if (server === undefined) return { started: false, problem: `${ISOLATED_CODE.noServer} at ${String(cell.rate)}/${String(cell.quantum)}` };
146
+ try {
147
+ const r = spawnSync(process.execPath, [join(toolsDir(), "isolated-step.mjs"), uri, String(ISOLATED_WINDOW_MS)], {
148
+ encoding: "utf8",
149
+ timeout: ISOLATED_WINDOW_MS + ISOLATED_STEP_SLACK_MS,
150
+ killSignal: "SIGKILL",
151
+ env: { ...env, JACK_DEFAULT_SERVER: server.name, [ISOLATED_SIGNAL_TOOL_ENV]: signal },
152
+ });
153
+ if (r.error !== undefined || r.status !== 0) return { started: false, problem: `isolated-step: ${r.error?.message ?? `exit ${String(r.status)}`}: ${(r.stderr ?? "").trim()}` };
154
+ return JSON.parse(r.stdout) as IsolatedRunFacts;
155
+ } finally {
156
+ server.stop();
157
+ }
158
+ };
159
+ }
160
+
161
+ /** The cell the step runs at: the rate and quantum the host's cost was judged at, else the first cell a Pi runs. */
162
+ export function isolatedCell(record: QualificationRecord, host: string): GridCell {
163
+ const at = record.cost.find((c) => c.host === host) ?? record.cost[0];
164
+ return at === undefined ? ADAPTER_PASS_CELLS[0]! : { rate: at.rate, quantum: at.quantum };
165
+ }
166
+
167
+ /** The pin once the step has spoken: its verdict word follows the step, and a step that could not run leaves none. Pure. */
168
+ export function pinAfterStep(pin: PinEntry | undefined, step: RunStep): PinEntry | undefined {
169
+ if (pin === undefined) return undefined;
170
+ const { verdict: _had, ...rest } = pin;
171
+ if (step.result === ISOLATED_RESULT.isolated) return { ...rest, verdict: "isolated" };
172
+ if (step.result === ISOLATED_RESULT.refused) return { ...rest, verdict: "refused" };
173
+ return rest;
174
+ }
175
+
176
+ /** The run id of a record from before the steps: its probe output's, read from the copy beside it. */
177
+ function runIdOf(record: QualificationRecord, dir: string): string | undefined {
178
+ if (record.run !== undefined) return record.run.id;
179
+ if (record.rejudged !== undefined) return record.rejudged.figures.runId;
180
+ try {
181
+ const read = readProbeOutput(readFileSync(join(dir, basename(record.probe.file)), "utf8"));
182
+ return read.ok ? read.output.run.id : undefined;
183
+ } catch {
184
+ return undefined;
185
+ }
186
+ }
187
+
188
+ /**
189
+ * The missing isolated step of a record already judged: the run keeps its id and gains the step, and nothing else is
190
+ * measured. `undefined` when it owes none, or has no run id to extend. Writes the record whole.
191
+ */
192
+ export function completeIsolated(path: string, record: QualificationRecord, uri: string, cell: GridCell, runner: IsolatedRunner, env: Readonly<Record<string, string | undefined>>): { readonly record: QualificationRecord; readonly step: RunStep } | undefined {
193
+ const isStep = (s: RunStep): boolean => s.step === RUN_STEP.isolated;
194
+ // a step that could not run decided nothing, so a pass tries it again and it takes that one's place
195
+ if (!owesIsolated(record) || record.run?.steps.some((s) => isStep(s) && s.result !== ISOLATED_RESULT.notRun) === true) return undefined;
196
+ const id = runIdOf(record, join(path, ".."));
197
+ if (id === undefined) return undefined;
198
+ const step = judgeIsolatedRun(runner(uri, cell, env));
199
+ const run: RunBlock = { id, steps: [...(record.run?.steps ?? baseSteps(record)).filter((s) => !isStep(s)), step] };
200
+ const next = { ...record, run };
201
+ writeFileWhole(path, `${JSON.stringify(next, null, 2)}\n`);
202
+ return { record: next, step };
203
+ }
@@ -46,7 +46,9 @@ import {
46
46
  type QualificationRecord,
47
47
  type RecordGridPoint,
48
48
  type RecordVerdict,
49
+ type RunBlock,
49
50
  } from "./qualification-record.js";
51
+ import { ISOLATED_RESULT, RUN_STEP, pinAfterStep, provenAlone } from "./qualify-isolated.js";
50
52
  import { PI_CELLS, PROBE_MEMORY_ONLY_ENV, PROBE_NOTES_ENV } from "./qualify-declarations.js";
51
53
  import { SYNC_STATE_SCHEMA, digestFiles, recordStem, type SyncContext, type SyncEntry, type SyncPlugin, type SyncState } from "./qualify-sync.js";
52
54
  import type { SuitabilityRating } from "./rating.js";
@@ -54,6 +56,7 @@ import { writeFileWhole } from "./whole-file.js";
54
56
 
55
57
  /** The codes of a measurement a record owes. */
56
58
  export const OWED_CODE = {
59
+ isolatedStep: "qualify.sync.isolated-step-owed",
57
60
  /** An LV2 verdict of a host that runs it through the adapter, and no measured adapter run. */
58
61
  adapterRun: "qualify.sync.adapter-run-owed",
59
62
  /** A verdict decided by a run under its host (mod-host, jalv), and no run in the record. */
@@ -192,8 +195,11 @@ export function rejudgeRecord(record: QualificationRecord, probeBytes: Uint8Arra
192
195
  if (!read.ok) throw new RejudgeRefusal(`${record.probe.file}: ${read.code}: ${read.problem}`);
193
196
  const output = withMemoryRun(read.output, record);
194
197
  const { profiles, kept } = rejudgeProfiles(record, output, options.host);
195
- const { reasons, owes } = runsOwed(record, output, profiles);
198
+ const { reasons, owes: runs } = runsOwed(record, output, profiles);
196
199
  const judgment = judgeProbeOutput(output, profiles, { admissions: admissionsOf(record), owed: reasons });
200
+ // decides no verdict: a record from before the steps that reads isolated stays offered, runs alone, and is not proven by the step
201
+ const readsIsolated = judgment.hosts.some((h) => h.verdict.measured && h.verdict.rating === "conditional");
202
+ const owes = readsIsolated && !provenAlone(record) ? [...runs, { measurement: "isolated-step" as const, code: OWED_CODE.isolatedStep }] : runs;
197
203
  const memory = record.memoryRun === undefined ? {} : { memory: { tool: record.memoryRun.tool, measuredAt: record.memoryRun.measuredAt } };
198
204
  const a = record.adapter;
199
205
  const adapterTaken = a?.versions === undefined || a.measuredAt === undefined ? {} : { adapter: { versions: a.versions, measuredAt: a.measuredAt } };
@@ -263,9 +269,17 @@ const readRecord = (path: string): QualificationRecord | undefined => {
263
269
  const nameOf = (record: QualificationRecord): string => record.plugin.id;
264
270
 
265
271
  /** 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. */
266
- function pluginWithVerdict(plugin: SyncPlugin, v: RecordVerdict): SyncPlugin {
272
+ function pluginWithVerdict(plugin: SyncPlugin, v: RecordVerdict, run?: RunBlock): SyncPlugin {
267
273
  const { pin, code: _code, ...rest } = plugin;
268
274
  const word = v.measured ? pinVerdictOf(v.rating as SuitabilityRating) : undefined;
275
+ // a rating does not make a plugin isolated: a run seen alone does, and a re-judge measures nothing. A record from before
276
+ // the steps keeps its word until a completion has run it.
277
+ if (word === "isolated" && run !== undefined) {
278
+ const step = run.steps.find((s) => s.step === RUN_STEP.isolated);
279
+ const kept = pinAfterStep(pin, step ?? { step: RUN_STEP.isolated, result: ISOLATED_RESULT.notRun });
280
+ const refused = step?.result === ISOLATED_RESULT.refused;
281
+ return { ...rest, outcome: refused ? "refused" : outcomeOfVerdict(v), ...(refused && step.code !== undefined ? { code: step.code } : v.deciding === undefined ? {} : { code: v.deciding }), ...(kept === undefined ? {} : { pin: kept }) };
282
+ }
269
283
  return {
270
284
  ...rest,
271
285
  outcome: outcomeOfVerdict(v),
@@ -331,7 +345,7 @@ export function rejudgeState(ctx: SyncContext, state: SyncState, now: () => stri
331
345
  const plugins = entry.plugins.map((plugin) => {
332
346
  const record = plugin.record === undefined ? undefined : judged.get(basename(plugin.record));
333
347
  const v = record?.verdicts.find((x) => x.host === hostProfile.id);
334
- if (v !== undefined) return pluginWithVerdict(plugin, v);
348
+ if (v !== undefined) return pluginWithVerdict(plugin, v, record?.run);
335
349
  if (record === undefined && plugin.outcome === "unmeasured" && plugin.record?.endsWith(RECORD_SUFFIX) !== true) {
336
350
  owing.push({ plugin: plugin.uri, ...(plugin.record === undefined ? {} : { record: basename(plugin.record) }), owes: [{ measurement: "qualification", code: OWED_CODE.noFigures }] });
337
351
  }
@@ -35,10 +35,11 @@ import type { Judgment } from "./probe-judge.js";
35
35
  import { qualifyTargets, type ProbeRun } from "./probe-runner.js";
36
36
  import { GRID_FULL, parseGridSpec, type GridSpec } from "./measurement-grid.js";
37
37
  import { adapterPass, type AdapterPassOptions, type AdapterPassResult } from "./qualify-adapter-pass.js";
38
+ import { ISOLATED_RESULT, baseSteps, completeIsolated, isolatedCell, missingSteps, judgeIsolatedRun, owesIsolated, pinAfterStep, spawnedIsolatedRunner, type IsolatedRunner } from "./qualify-isolated.js";
38
39
  import { lv2World, rejudgeState, memoryPass, type MemoryPassResult, type RejudgeResult } from "./qualify-rejudge.js";
39
40
  import { memoryBoundOf, parseMemoryLimit, type MemoryBound } from "./memory-bound.js";
40
- import { writeQualification, type NotMeasured } from "./qualification-record.js";
41
- import { DESCRIPTOR_DIR_STATE_SUBDIR, DESCRIPTOR_DIR_SYSTEM, MEMORY_PASS_PROBE_NOTES, QUALIFY_QUANTA, QUALIFY_RATES } from "./qualify-declarations.js";
41
+ import { rewriteRecord, writeQualification, type NotMeasured, type QualificationRecord } from "./qualification-record.js";
42
+ import { ADMIT_STEP_MS_ENV, DESCRIPTOR_DIR_STATE_SUBDIR, DESCRIPTOR_DIR_SYSTEM, MEMORY_PASS_PROBE_NOTES, QUALIFY_QUANTA, QUALIFY_RATES } from "./qualify-declarations.js";
42
43
  import { toolsDir } from "./tool-paths.js";
43
44
  import { writeFileWhole } from "./whole-file.js";
44
45
 
@@ -139,6 +140,8 @@ export interface SyncArgs {
139
140
  readonly memoryOnly: boolean;
140
141
  /** `--adapter-only`: the run of each LV2 plugin under the adapter at the Pi's cells, merged into its record. */
141
142
  readonly adapterOnly: boolean;
143
+ /** `--isolated-only`: the missing isolated step of each record already judged, extending its run. */
144
+ readonly isolatedOnly: boolean;
142
145
  readonly help: boolean;
143
146
  /** The settings given as flags, by key. */
144
147
  readonly values: ReadonlyMap<string, string>;
@@ -158,6 +161,7 @@ export function parseSyncArgs(argv: readonly string[]): { ok: true; args: SyncAr
158
161
  let rejudge = false;
159
162
  let memoryOnly = false;
160
163
  let adapterOnly = false;
164
+ let isolatedOnly = false;
161
165
  let help = false;
162
166
  for (let i = 0; i < argv.length; i++) {
163
167
  const a = argv[i]!;
@@ -169,6 +173,7 @@ export function parseSyncArgs(argv: readonly string[]): { ok: true; args: SyncAr
169
173
  else if (a === "--rejudge") rejudge = true;
170
174
  else if (a === "--memory-only") memoryOnly = true;
171
175
  else if (a === "--adapter-only") adapterOnly = true;
176
+ else if (a === "--isolated-only") isolatedOnly = true;
172
177
  else if (a === "--help") help = true;
173
178
  else if (a === "--grid-hosts") values.set("grid", "hosts");
174
179
  else if (a === "--config" || byFlag.has(a)) {
@@ -178,9 +183,9 @@ export function parseSyncArgs(argv: readonly string[]): { ok: true; args: SyncAr
178
183
  else values.set(byFlag.get(a)!, v);
179
184
  } else return { ok: false, error: `unknown argument ${a}` };
180
185
  }
181
- if (rejudge && (memoryOnly || adapterOnly || force || refreshPinsOnly)) return { ok: false, error: "--rejudge measures nothing: it goes with none of --memory-only, --adapter-only, --force and --refresh-pins" };
182
- if ([memoryOnly, adapterOnly, refreshPinsOnly].filter(Boolean).length > 1) return { ok: false, error: "--memory-only, --adapter-only and --refresh-pins are different runs: one at a time" };
183
- return { ok: true, args: { ...(config === undefined ? {} : { config }), system, force, noDistributed, noWait, refreshPins: refreshPinsOnly, rejudge, memoryOnly, adapterOnly, help, values } };
186
+ if (rejudge && (memoryOnly || adapterOnly || isolatedOnly || force || refreshPinsOnly)) return { ok: false, error: "--rejudge measures nothing: it goes with none of --memory-only, --adapter-only, --force and --refresh-pins" };
187
+ if ([memoryOnly, adapterOnly, isolatedOnly, refreshPinsOnly].filter(Boolean).length > 1) return { ok: false, error: "--memory-only, --adapter-only and --refresh-pins are different runs: one at a time" };
188
+ return { ok: true, args: { ...(config === undefined ? {} : { config }), system, force, noDistributed, noWait, refreshPins: refreshPinsOnly, rejudge, memoryOnly, adapterOnly, isolatedOnly, help, values } };
184
189
  }
185
190
 
186
191
  export function syncHelpText(): string {
@@ -196,6 +201,7 @@ export function syncHelpText(): string {
196
201
  " --refresh-pins only bring every pin's files up to the ones plugin-hostd names, measuring nothing",
197
202
  " --rejudge derive every record's verdicts, tiers and pin again from the figures it holds; measures nothing",
198
203
  " --memory-only measure only the footprint of each recorded plugin at the Pi's cells and merge it; with --force, those already merged too",
204
+ " --isolated-only run only the isolated step each record still owes, adding it to the record's run",
199
205
  " --adapter-only run each LV2 plugin only under omx-clap-host's LV2 adapter at the Pi's cells, on private JACK servers, and merge it; with --force, those already done too",
200
206
  " --grid-hosts the fast grid, the same as --grid hosts (7 cells with the shipped hosts)",
201
207
  " --help this text",
@@ -613,6 +619,8 @@ export interface SyncContext {
613
619
  readonly log: (line: string) => void;
614
620
  /** The run of `plugin-hostd --pin-files`, a stand-in in tests. */
615
621
  readonly pinFilesRun?: PinFilesRun;
622
+ /** The isolated step, a stand-in in tests. */
623
+ readonly isolatedRunner?: IsolatedRunner;
616
624
  }
617
625
 
618
626
  /**
@@ -696,13 +704,41 @@ function writeUnmeasuredRecord(ctx: SyncContext, item: SyncItem, files: readonly
696
704
  return path;
697
705
  }
698
706
 
707
+ /** The step's environment: each of its commands has the sync's budget. */
708
+ const isolatedEnv = (ctx: SyncContext): Readonly<Record<string, string | undefined>> => ({ ...ctx.env, [ADMIT_STEP_MS_ENV]: String(ctx.settings.budgetS * 1000) });
709
+
699
710
  /** Each judged run of a pass as the plugins it qualified: the record written, the pin taken, the outcome. */
700
- function judgedPlugins(ctx: SyncContext, runs: readonly { bytes: Buffer; judgment: Judgment }[]): SyncPlugin[] {
711
+ function judgedPlugins(ctx: SyncContext, runs: readonly { bytes: Buffer; judgment: Judgment }[], describeProblem?: string): SyncPlugin[] {
701
712
  const host = ctx.settings.hostProfile.id;
702
713
  const entries = runs.map(({ bytes, judgment }) => {
703
- const { recordPath } = writeQualification(ctx.settings.qualificationDir, bytes, judgment, QUICK_NOT_MEASURED);
704
- const pin = withHostdFiles(ctx, judgmentPinEntries(judgment, host)[0]);
705
- const { outcome, code } = outcomeOf(judgment, host);
714
+ const { recordPath, record } = writeQualification(ctx.settings.qualificationDir, bytes, judgment, QUICK_NOT_MEASURED);
715
+ const steps = baseSteps(record, describeProblem);
716
+ let pin = withHostdFiles(ctx, judgmentPinEntries(judgment, host)[0]);
717
+ let { outcome, code } = outcomeOf(judgment, host);
718
+ if (owesIsolated(record)) {
719
+ // in this same pass: a record's verdict=isolated is a run seen alone, never a rating alone
720
+ const uri = pin?.uri ?? judgment.output.plugin.id;
721
+ const runner = ctx.isolatedRunner ?? spawnedIsolatedRunner();
722
+ const step = judgeIsolatedRun(runner(uri, isolatedCell(record, host), isolatedEnv(ctx)));
723
+ steps.push(step);
724
+ pin = pinAfterStep(pin, step);
725
+ // a crash or a hang in the probe stays what it was unless the step saw the plugin run: only a rating waited on the step
726
+ if (step.result === ISOLATED_RESULT.refused) {
727
+ if (outcome === "qualified") {
728
+ outcome = "refused";
729
+ code = step.code;
730
+ }
731
+ } else if (step.result === ISOLATED_RESULT.notRun) {
732
+ if (outcome === "qualified") {
733
+ outcome = "unmeasured";
734
+ code = step.code;
735
+ }
736
+ } else if (outcome !== "qualified") {
737
+ outcome = "qualified";
738
+ code = undefined;
739
+ }
740
+ }
741
+ rewriteRecord(recordPath, { ...record, run: { id: judgment.output.run.id, steps } });
706
742
  const plugin: SyncPlugin = { uri: pin?.uri ?? judgment.output.plugin.id, outcome, ...(code === undefined ? {} : { code }), ...(pin === undefined ? {} : { pin }), record: recordPath };
707
743
  return { plugin, judgedHere: judgment.hosts.some((h) => h.verdict.host === host) };
708
744
  });
@@ -796,7 +832,7 @@ function qualifyClap(ctx: SyncContext, item: SyncItem): SyncPlugin[] {
796
832
  const pass = runPass(ctx, { format: "clap", uri: `urn:clap:${p.id}`, bundle: item.root, binary: item.files[0]!, binaryPath: file }, ctx.env);
797
833
  for (const f of pass.failures) out.push({ uri, outcome: "unmeasured", code: SYNC_CODE.notMeasured, detail: f.problem });
798
834
  const problem = descriptorFailed.get(p.id);
799
- out.push(...judgedPlugins(ctx, pass.runs).map((j) => (problem === undefined ? j : { ...j, descriptorProblem: problem })));
835
+ out.push(...judgedPlugins(ctx, pass.runs, problem).map((j) => (problem === undefined ? j : { ...j, descriptorProblem: problem })));
800
836
  }
801
837
  return out;
802
838
  }
@@ -826,6 +862,46 @@ export function qualifyItem(ctx: SyncContext, item: SyncItem, files: readonly Fi
826
862
  return { format: item.format, root: item.root, files: all, plugins, qualifiedAt: new Date(started).toISOString(), seconds: (Date.now() - started) / 1000 };
827
863
  }
828
864
 
865
+ /**
866
+ * The isolated steps the records of a state still owe, each extending its record's run. A plugin's pin follows its step, and
867
+ * a step that could not run stays owed: the next pass tries it again.
868
+ */
869
+ export function isolatedPass(ctx: SyncContext, state: SyncState): { readonly state: SyncState; readonly isolated: number; readonly refused: number; readonly notRun: number } {
870
+ const count = { isolated: 0, refused: 0, notRun: 0 };
871
+ const runner = ctx.isolatedRunner ?? spawnedIsolatedRunner();
872
+ const host = ctx.settings.hostProfile.id;
873
+ const entries: Record<string, SyncEntry> = {};
874
+ for (const [key, entry] of Object.entries(state.entries)) {
875
+ entries[key] = {
876
+ ...entry,
877
+ plugins: entry.plugins.map((p) => {
878
+ if (p.record === undefined || !p.record.endsWith(".qualification.json")) return p;
879
+ const path = join(ctx.settings.qualificationDir, basename(p.record));
880
+ let record: QualificationRecord;
881
+ try {
882
+ record = JSON.parse(readFileSync(path, "utf8")) as QualificationRecord;
883
+ } catch {
884
+ ctx.log(`${p.uri}: its record does not read; no isolated step`);
885
+ return p;
886
+ }
887
+ const done = completeIsolated(path, record, p.pin?.uri ?? record.plugin.id, isolatedCell(record, host), runner, isolatedEnv(ctx));
888
+ if (done === undefined) return p;
889
+ ctx.log(`${p.uri}: isolated step ${done.step.result}${done.step.code === undefined ? "" : ` (${done.step.code})`}`);
890
+ const pin = pinAfterStep(p.pin, done.step);
891
+ const { pin: _pin, code: _code, ...rest } = p;
892
+ if (done.step.result === ISOLATED_RESULT.isolated) {
893
+ count.isolated++;
894
+ return { ...rest, outcome: "qualified" as const, ...(pin === undefined ? {} : { pin }) };
895
+ }
896
+ if (done.step.result === ISOLATED_RESULT.refused) count.refused++;
897
+ else count.notRun++;
898
+ return { ...rest, outcome: done.step.result === ISOLATED_RESULT.refused ? ("refused" as const) : ("unmeasured" as const), ...(done.step.code === undefined ? {} : { code: done.step.code }), ...(pin === undefined ? {} : { pin }) };
899
+ }),
900
+ };
901
+ }
902
+ return { ...count, state: count.isolated + count.refused + count.notRun === 0 ? state : { ...state, entries } };
903
+ }
904
+
829
905
  // --- the distributed records ----------------------------------------------------------------------------------------
830
906
 
831
907
  /** The layer a run reads: none under `--force` or `--no-distributed`, or without a host class. */
@@ -856,6 +932,13 @@ function takeDistributed(ctx: SyncContext, layer: DistributedLayer, item: SyncIt
856
932
  for (const line of match.lines) ctx.log(line);
857
933
  return undefined;
858
934
  }
935
+ for (const r of match.records) {
936
+ const missing = missingSteps(r.record);
937
+ if (missing.length > 0) {
938
+ ctx.log(`distributed: ${distributedUri(item, r)}: its record lacks the step${missing.length === 1 ? "" : "s"} ${missing.join(", ")}; measured here`);
939
+ return undefined;
940
+ }
941
+ }
859
942
  let plugins: SyncPlugin[];
860
943
  try {
861
944
  plugins = applyDistributed(ctx.settings.qualificationDir, item, match, host);
@@ -1096,6 +1179,8 @@ export interface SyncSummary {
1096
1179
  readonly memoryOnly?: { readonly measured: number; readonly already: number; readonly failed: number };
1097
1180
  /** Every plugin the run measured was unmeasured for this one reason of the machine ({@link machineReason}): the run failed. */
1098
1181
  readonly machineFailure?: string;
1182
+ /** `--isolated-only`: how the steps ended. */
1183
+ readonly isolatedOnly?: { readonly isolated: number; readonly refused: number; readonly notRun: number };
1099
1184
  /** `--adapter-only`: the plugins run, those done already at these host versions, and those whose host could not be run. */
1100
1185
  readonly adapterOnly?: { readonly measured: number; readonly already: number; readonly failed: number };
1101
1186
  }
@@ -1184,6 +1269,10 @@ export interface SyncRunOptions {
1184
1269
  readonly rejudge?: boolean;
1185
1270
  /** `--memory-only`: take only the footprints; `force` takes those already merged again. */
1186
1271
  readonly memoryOnly?: boolean;
1272
+ /** The isolated step, a stand-in in tests. */
1273
+ readonly isolatedRunner?: IsolatedRunner;
1274
+ /** `--isolated-only`: take only the isolated steps records still owe. */
1275
+ readonly isolatedOnly?: boolean;
1187
1276
  /** `--adapter-only`: take only the adapter runs; `force` takes those already done again. */
1188
1277
  readonly adapterOnly?: boolean;
1189
1278
  /** Stand-ins for the pass's hosts, in tests. */
@@ -1251,6 +1340,7 @@ function addSummaries(a: SyncSummary, b: SyncSummary): SyncSummary {
1251
1340
  ...(refreshed === 0 ? {} : { refreshed }),
1252
1341
  ...(b.rejudge === undefined ? (a.rejudge === undefined ? {} : { rejudge: a.rejudge }) : { rejudge: b.rejudge }),
1253
1342
  ...(b.memoryOnly === undefined ? (a.memoryOnly === undefined ? {} : { memoryOnly: a.memoryOnly }) : { memoryOnly: b.memoryOnly }),
1343
+ ...(b.isolatedOnly === undefined ? (a.isolatedOnly === undefined ? {} : { isolatedOnly: a.isolatedOnly }) : { isolatedOnly: b.isolatedOnly }),
1254
1344
  ...(b.adapterOnly === undefined ? (a.adapterOnly === undefined ? {} : { adapterOnly: a.adapterOnly }) : { adapterOnly: b.adapterOnly }),
1255
1345
  };
1256
1346
  }
@@ -1276,7 +1366,7 @@ async function runLocked(settings: SyncSettings, options: SyncRunOptions): Promi
1276
1366
  {
1277
1367
  const started = Date.now();
1278
1368
  const dirs = [...settings.clapDirs, ...settings.lv2Dirs];
1279
- const ctx: SyncContext = { settings, env, log, ...(options.pinFilesRun === undefined ? {} : { pinFilesRun: options.pinFilesRun }) };
1369
+ const ctx: SyncContext = { settings, env, log, ...(options.pinFilesRun === undefined ? {} : { pinFilesRun: options.pinFilesRun }), ...(options.isolatedRunner === undefined ? {} : { isolatedRunner: options.isolatedRunner }) };
1280
1370
  // the pins first, before the quiet wait and any measurement: an upgrade of plugin-hostd that collects more files
1281
1371
  // than the pins on disk hold refuses those plugins until their pins name them (plugin-qualify#239)
1282
1372
  const fresh = refreshPins(ctx, readSyncState(settings.stateFile, log));
@@ -1290,6 +1380,12 @@ async function runLocked(settings: SyncSettings, options: SyncRunOptions): Promi
1290
1380
  const summary = await sendRefreshed(settings, r.state, fresh.refreshed.length, started, log);
1291
1381
  return { ...summary, rejudge: { changed: r.changed, unchanged: r.unchanged, owing: r.owing.length } };
1292
1382
  }
1383
+ if (options.isolatedOnly === true) {
1384
+ const r = isolatedPass(ctx, fresh.state);
1385
+ if (r.state !== fresh.state) writeFileWhole(settings.stateFile, `${JSON.stringify(r.state, null, 1)}\n`);
1386
+ const summary = await sendRefreshed(settings, r.state, fresh.refreshed.length, started, log);
1387
+ return { ...summary, isolatedOnly: { isolated: r.isolated, refused: r.refused, notRun: r.notRun } };
1388
+ }
1293
1389
  if (options.adapterOnly === true) {
1294
1390
  const r: AdapterPassResult = adapterPass(ctx, fresh.state, { ...options.adapterPass, force: options.force === true });
1295
1391
  log(`adapter runs under ${Object.entries(r.versions).map(([k, v]) => `${k} ${v}`).join(", ")}`);
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: GPL-3.0-or-later
3
+ // Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
4
+ // One isolated step (plugin-qualify#249): a plugin-hostd of its own on the JACK server the environment names, the plugin
5
+ // added alone, worker_list read, a sine through it for a window, and worker_list read again. Prints one JSON line for
6
+ // src/qualify-isolated.ts to judge. A host that never listened says started: false: nothing was learned about the plugin.
7
+ import { spawn, spawnSync } from "node:child_process";
8
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
9
+ import { createConnection } from "node:net";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+
13
+ const [uri, windowMsWord] = process.argv.slice(2);
14
+ const windowMs = Number(windowMsWord);
15
+ const signalTool = process.env.OMX_ISOLATED_SIGNAL;
16
+ const hostd = process.env.PLUGIN_HOSTD ?? "plugin-hostd";
17
+ const port = 40000 + (process.pid % 20000);
18
+ const CONNECT_TRIES = 50;
19
+ // the sync gives each command its budget: a plugin that hangs in add ends there
20
+ const COMMAND_MS = Number(process.env.OMX_ADMIT_STEP_MS ?? 0) || 30000;
21
+ const INSTANCE = 0;
22
+ const CLOCK_TICKS = 100;
23
+ const WORKER_SPAWN = -503;
24
+
25
+ const state = mkdtempSync(join(tmpdir(), "plugin-qualify-isolated-"));
26
+ const host = spawn(hostd, ["-n", "-p", String(port)], { stdio: "ignore", detached: true, env: { ...process.env, XDG_STATE_HOME: state } });
27
+ let ended = false;
28
+ host.on("exit", () => (ended = true));
29
+ host.on("error", () => (ended = true));
30
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
31
+
32
+ async function connect() {
33
+ for (let i = 0; i < CONNECT_TRIES && !ended; i++) {
34
+ const s = await new Promise((resolve) => {
35
+ const c = createConnection({ port, host: "127.0.0.1" }, () => resolve(c));
36
+ c.on("error", () => resolve(undefined));
37
+ });
38
+ if (s !== undefined) return s;
39
+ await sleep(100);
40
+ }
41
+ return undefined;
42
+ }
43
+
44
+ function commander(sock) {
45
+ let buffer = "";
46
+ let closed = false;
47
+ sock.on("data", (d) => (buffer += d.toString()));
48
+ sock.on("close", () => (closed = true));
49
+ sock.on("error", () => (closed = true));
50
+ return async (line) => {
51
+ buffer = "";
52
+ sock.write(`${line}\0`);
53
+ const until = Date.now() + COMMAND_MS;
54
+ while (Date.now() < until && !closed) {
55
+ const m = /resp (-?\d+)([^\0]*)\0?/.exec(buffer);
56
+ if (m !== null && buffer.includes("\0")) return { code: Number(m[1]), rest: m[2].trim() };
57
+ await sleep(20);
58
+ }
59
+ return undefined;
60
+ };
61
+ }
62
+
63
+ /** `<worker>:<pid>:<format>:<state>:<core>:<pool>:<place>:<i>,<i>,...` per worker. */
64
+ const workersOf = (answer) =>
65
+ (answer?.rest ?? "").split(/\s+/).filter(Boolean).map((w) => {
66
+ const f = w.split(":");
67
+ return { pid: Number(f[1]), instances: (f[7] ?? "").split(",").filter(Boolean) };
68
+ });
69
+
70
+ function cpuTicks(pid) {
71
+ try {
72
+ const after = readFileSync(`/proc/${String(pid)}/stat`, "utf8").replace(/^.*\) /, "").split(" ");
73
+ return Number(after[11]) + Number(after[12]);
74
+ } catch {
75
+ return undefined;
76
+ }
77
+ }
78
+
79
+ async function main() {
80
+ const sock = await connect();
81
+ if (sock === undefined) return { started: false, problem: `${hostd} did not listen` };
82
+ const send = commander(sock);
83
+ const added = await send(`add ${uri} ${String(INSTANCE)}`);
84
+ // WORKER_SPAWN: no worker came up to hold the plugin, which says nothing of the plugin. The next pass tries it again.
85
+ if (added?.code === WORKER_SPAWN) return { started: false, problem: `no worker came up: add answered ${String(WORKER_SPAWN)}` };
86
+ if (added === undefined || added.code < 0) return { started: true, added: false, problem: added === undefined ? "the host gave no answer to add" : `add answered ${String(added.code)}` };
87
+ const before = workersOf(await send("worker_list"));
88
+ const worker = before[0];
89
+ const ticksBefore = worker === undefined ? undefined : cpuTicks(worker.pid);
90
+ const signal = spawnSync(signalTool, [`effect_${String(INSTANCE)}`, String(windowMs)], { encoding: "utf8", timeout: windowMs + 15000, killSignal: "SIGKILL" });
91
+ const ticksAfter = worker === undefined ? undefined : cpuTicks(worker.pid);
92
+ const after = workersOf(await send("worker_list"));
93
+ sock.destroy();
94
+ const alive = after.length > 0 && after[0].pid === worker?.pid && ticksAfter !== undefined;
95
+ if (signal.error !== undefined || signal.status !== 0) {
96
+ if (signal.status === 2 && alive) return { started: false, problem: `isolated_signal: ${signal.stderr.trim()}` };
97
+ return { started: true, added: true, alive, workers: before.length, instancesInWorker: worker?.instances.length ?? 0, problem: signal.stderr?.trim() };
98
+ }
99
+ const heard = JSON.parse(signal.stdout);
100
+ return {
101
+ started: true,
102
+ added: true,
103
+ alive,
104
+ workers: before.length,
105
+ instancesInWorker: worker?.instances.length ?? 0,
106
+ latencyFrames: heard.latencyFrames,
107
+ xruns: heard.xruns,
108
+ finite: heard.finite,
109
+ silent: heard.silent,
110
+ ...(ticksBefore === undefined || ticksAfter === undefined ? {} : { cpuPercent: ((ticksAfter - ticksBefore) * 100000) / CLOCK_TICKS / windowMs }),
111
+ };
112
+ }
113
+
114
+ const result = await main();
115
+ try {
116
+ process.kill(-host.pid, "SIGKILL");
117
+ } catch {
118
+ // the host went on its own
119
+ }
120
+ rmSync(state, { recursive: true, force: true });
121
+ process.stdout.write(`${JSON.stringify(result)}\n`);
122
+ process.exit(0);