@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.
- package/README.md +9 -1
- package/bin/plugin-qualify-sync.mjs +1 -1
- package/dist/clap-qualify.d.ts +3 -1
- package/dist/clap-qualify.js +3 -1
- package/dist/clap-qualify.js.map +1 -1
- package/dist/host-admission.d.ts +19 -0
- package/dist/host-admission.js +21 -0
- package/dist/host-admission.js.map +1 -1
- package/dist/hostd-pin-files.js +3 -1
- package/dist/hostd-pin-files.js.map +1 -1
- package/dist/measurement/format.d.ts +10 -0
- package/dist/measurement/format.js +8 -0
- package/dist/measurement/format.js.map +1 -1
- package/dist/measurement/probe-json.js +2 -1
- package/dist/measurement/probe-json.js.map +1 -1
- package/dist/probe-judge.js +2 -2
- package/dist/probe-judge.js.map +1 -1
- package/dist/qualification-record.d.ts +25 -2
- package/dist/qualification-record.js.map +1 -1
- package/dist/qualify-adapter-pass.js +21 -3
- package/dist/qualify-adapter-pass.js.map +1 -1
- package/dist/qualify-declarations.d.ts +5 -0
- package/dist/qualify-declarations.js +7 -0
- package/dist/qualify-declarations.js.map +1 -1
- package/dist/qualify-isolated.d.ts +70 -0
- package/dist/qualify-isolated.js +186 -0
- package/dist/qualify-isolated.js.map +1 -0
- package/dist/qualify-rejudge.d.ts +1 -0
- package/dist/qualify-rejudge.js +16 -3
- package/dist/qualify-rejudge.js.map +1 -1
- package/dist/qualify-sync.d.ts +25 -0
- package/dist/qualify-sync.js +104 -11
- package/dist/qualify-sync.js.map +1 -1
- package/package.json +1 -1
- package/src/clap-qualify.ts +5 -1
- package/src/host-admission.ts +27 -0
- package/src/hostd-pin-files.ts +3 -1
- package/src/measurement/format.ts +17 -0
- package/src/measurement/probe-json.ts +2 -1
- package/src/probe-judge.ts +2 -2
- package/src/qualification-record.ts +28 -2
- package/src/qualify-adapter-pass.ts +19 -2
- package/src/qualify-declarations.ts +7 -0
- package/src/qualify-isolated.ts +203 -0
- package/src/qualify-rejudge.ts +17 -3
- package/src/qualify-sync.ts +107 -11
- package/tools/isolated-step.mjs +122 -0
- 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
|
+
}
|
package/src/qualify-rejudge.ts
CHANGED
|
@@ -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
|
}
|
package/src/qualify-sync.ts
CHANGED
|
@@ -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
|
|
705
|
-
|
|
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);
|