@openmixer/plugin-qualify 0.20.1 → 0.20.4
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/bin/plugin-qualify-sync.mjs +1 -0
- package/catalogs/ca.json +251 -0
- package/catalogs/de.json +49 -0
- package/catalogs/es.json +49 -0
- package/catalogs/fr.json +49 -0
- package/catalogs/it.json +49 -0
- package/catalogs/pt.json +49 -0
- package/dist/clap-loader.js +2 -1
- package/dist/clap-loader.js.map +1 -1
- package/dist/clap-qualify.d.ts +11 -0
- package/dist/clap-qualify.js +16 -0
- package/dist/clap-qualify.js.map +1 -1
- package/dist/compare.d.ts +12 -11
- package/dist/compare.js +71 -38
- package/dist/compare.js.map +1 -1
- package/dist/host-admission.d.ts +25 -0
- package/dist/host-admission.js +41 -5
- package/dist/host-admission.js.map +1 -1
- package/dist/hostd-pin-files.d.ts +6 -2
- package/dist/hostd-pin-files.js +10 -6
- package/dist/hostd-pin-files.js.map +1 -1
- package/dist/hostd-search-path.d.ts +38 -0
- package/dist/hostd-search-path.js +58 -0
- package/dist/hostd-search-path.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/jack-registry.js +1 -1
- package/dist/lv2-loader.d.ts +33 -1
- package/dist/lv2-loader.js +82 -19
- package/dist/lv2-loader.js.map +1 -1
- package/dist/measurement/vocabulary.js +3 -3
- package/dist/measurement/vocabulary.js.map +1 -1
- package/dist/measurement-grid.d.ts +1 -1
- package/dist/measurement-grid.js +5 -3
- package/dist/measurement-grid.js.map +1 -1
- package/dist/pins-file.d.ts +3 -2
- package/dist/pins-file.js +3 -2
- package/dist/pins-file.js.map +1 -1
- package/dist/plugin-db-cli.d.ts +28 -0
- package/dist/plugin-db-cli.js +43 -0
- package/dist/plugin-db-cli.js.map +1 -0
- package/dist/plugin-db-pin.d.ts +8 -0
- package/dist/plugin-db-pin.js +29 -0
- package/dist/plugin-db-pin.js.map +1 -0
- package/dist/probe-judge.js +5 -0
- package/dist/probe-judge.js.map +1 -1
- package/dist/probe-output.d.ts +10 -0
- package/dist/probe-output.js +16 -0
- package/dist/probe-output.js.map +1 -1
- package/dist/probe-runner.js +24 -5
- package/dist/probe-runner.js.map +1 -1
- package/dist/qualification-record.d.ts +5 -0
- package/dist/qualification-record.js +13 -1
- package/dist/qualification-record.js.map +1 -1
- package/dist/qualify-adapter-pass.d.ts +1 -2
- package/dist/qualify-adapter-pass.js +6 -6
- package/dist/qualify-adapter-pass.js.map +1 -1
- package/dist/qualify-declarations.d.ts +23 -6
- package/dist/qualify-declarations.js +25 -6
- package/dist/qualify-declarations.js.map +1 -1
- package/dist/qualify-isolated.d.ts +9 -0
- package/dist/qualify-isolated.js +16 -5
- package/dist/qualify-isolated.js.map +1 -1
- package/dist/qualify-pool.js +4 -3
- package/dist/qualify-pool.js.map +1 -1
- package/dist/qualify-rejudge.js +3 -3
- package/dist/qualify-rejudge.js.map +1 -1
- package/dist/qualify-sync.d.ts +12 -5
- package/dist/qualify-sync.js +51 -30
- package/dist/qualify-sync.js.map +1 -1
- package/dist/record-figures.d.ts +52 -0
- package/dist/record-figures.js +58 -0
- package/dist/record-figures.js.map +1 -0
- package/dist/refused-stderr-kept.d.ts +12 -0
- package/dist/refused-stderr-kept.js +67 -0
- package/dist/refused-stderr-kept.js.map +1 -0
- package/dist/run-home.d.ts +4 -0
- package/dist/run-home.js +26 -0
- package/dist/run-home.js.map +1 -0
- package/dist/text-catalog.d.ts +41 -0
- package/dist/text-catalog.js +97 -0
- package/dist/text-catalog.js.map +1 -0
- package/dist/text-codes.d.ts +29 -0
- package/dist/text-codes.generated.d.ts +88 -0
- package/dist/text-codes.generated.js +96 -0
- package/dist/text-codes.generated.js.map +1 -0
- package/dist/text-codes.js +19 -0
- package/dist/text-codes.js.map +1 -0
- package/package.json +8 -6
- package/schema/codes.json +386 -0
- package/src/clap-loader.ts +2 -1
- package/src/clap-qualify.ts +25 -0
- package/src/compare.ts +85 -45
- package/src/host-admission.ts +52 -5
- package/src/hostd-pin-files.ts +13 -8
- package/src/hostd-search-path.ts +85 -0
- package/src/index.ts +4 -0
- package/src/jack-registry.ts +1 -1
- package/src/lv2-loader.ts +89 -17
- package/src/measurement/vocabulary.ts +3 -3
- package/src/measurement-grid.ts +5 -3
- package/src/pins-file.ts +3 -2
- package/src/plugin-db-cli.ts +78 -0
- package/src/plugin-db-pin.ts +33 -0
- package/src/probe-judge.ts +5 -0
- package/src/probe-output.ts +23 -0
- package/src/probe-runner.ts +25 -4
- package/src/qualification-record.ts +13 -1
- package/src/qualify-adapter-pass.ts +6 -5
- package/src/qualify-declarations.ts +27 -6
- package/src/qualify-isolated.ts +18 -5
- package/src/qualify-pool.ts +4 -3
- package/src/qualify-rejudge.ts +3 -3
- package/src/qualify-sync.ts +55 -33
- package/src/record-figures.ts +114 -0
- package/src/refused-stderr-kept.ts +84 -0
- package/src/run-home.ts +28 -0
- package/src/text-catalog.ts +116 -0
- package/src/text-codes.generated.ts +97 -0
- package/src/text-codes.ts +35 -0
- package/tools/_declarations.py +2 -1
- package/tools/catalogs-gate.sh +22 -0
- package/tools/compare-host.mjs +1 -1
- package/tools/compare-run.mjs +10 -6
- package/tools/descriptors.py +40 -3
- package/tools/gen-python-declarations.mjs +1 -0
- package/tools/gen-text-codes.d.mts +8 -0
- package/tools/gen-text-codes.mjs +77 -0
- package/tools/isolated-step.mjs +24 -3
- package/tools/mod-host-admit.mjs +57 -1
- package/tools/scan.py +29 -5
package/src/qualify-isolated.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { spawnSync } from "node:child_process";
|
|
|
16
16
|
import { existsSync, readFileSync } from "node:fs";
|
|
17
17
|
import { basename, join } from "node:path";
|
|
18
18
|
|
|
19
|
+
import { withRunHome } from "./run-home.js";
|
|
19
20
|
import { ADAPTER_PASS_CELLS, startPrivateJack, type JackStarter } from "./qualify-adapter-pass.js";
|
|
20
21
|
import type { GridCell } from "./measurement-grid.js";
|
|
21
22
|
import { clapPinUri, type PinEntry } from "./pins-file.js";
|
|
@@ -130,6 +131,21 @@ export function pinFromRecord(record: QualificationRecord): PinEntry | undefined
|
|
|
130
131
|
return pinAfterStep({ uri, integrity: { files: sorted, layout } }, step);
|
|
131
132
|
}
|
|
132
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Whether a step of the isolated kind is a verdict. A refusal (`isolated.silent-output`, `isolated.not-added`) is one: the
|
|
136
|
+
* plugin was tried and the answer is no. A step that could not run decided nothing, and neither did silence before the
|
|
137
|
+
* notes were played where a plugin could hear them: a step of that tool does not say how many inputs it linked. Those two
|
|
138
|
+
* are owed, and a pass tries them again.
|
|
139
|
+
*/
|
|
140
|
+
export function isolatedDecided(s: RunStep): boolean {
|
|
141
|
+
return s.step === RUN_STEP.isolated && s.result !== ISOLATED_RESULT.notRun && !(s.code === ISOLATED_CODE.silent && s.figures?.midiInputsLinked === undefined);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Whether the record holds a finished isolated step, of whatever answer: nothing about it is owed. */
|
|
145
|
+
export function isolatedFinished(record: QualificationRecord): boolean {
|
|
146
|
+
return record.run?.steps.some(isolatedDecided) === true;
|
|
147
|
+
}
|
|
148
|
+
|
|
133
149
|
/** Whether the isolated step saw this plugin running alone. A record's word alone never says so. */
|
|
134
150
|
export function provenAlone(record: QualificationRecord): boolean {
|
|
135
151
|
return record.run?.steps.some((s) => s.step === RUN_STEP.isolated && s.result === ISOLATED_RESULT.isolated) === true;
|
|
@@ -182,7 +198,7 @@ export function spawnedIsolatedRunner(jack: JackStarter = startPrivateJack): Iso
|
|
|
182
198
|
encoding: "utf8",
|
|
183
199
|
timeout: ISOLATED_WINDOW_MS + ISOLATED_STEP_SLACK_MS,
|
|
184
200
|
killSignal: "SIGKILL",
|
|
185
|
-
env: { ...env, JACK_DEFAULT_SERVER: server.name, [ISOLATED_SIGNAL_TOOL_ENV]: signal },
|
|
201
|
+
env: { ...withRunHome(env), JACK_DEFAULT_SERVER: server.name, [ISOLATED_SIGNAL_TOOL_ENV]: signal },
|
|
186
202
|
});
|
|
187
203
|
if (r.error !== undefined || r.status !== 0) return { started: false, problem: `isolated-step: ${r.error?.message ?? `exit ${String(r.status)}`}: ${(r.stderr ?? "").trim()}` };
|
|
188
204
|
return JSON.parse(r.stdout) as IsolatedRunFacts;
|
|
@@ -225,10 +241,7 @@ export function runIdOf(record: QualificationRecord, dir: string): string | unde
|
|
|
225
241
|
*/
|
|
226
242
|
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 {
|
|
227
243
|
const isStep = (s: RunStep): boolean => s.step === RUN_STEP.isolated;
|
|
228
|
-
|
|
229
|
-
// hear them: a step of that tool does not say how many inputs it linked. A pass tries these again, and it takes their place
|
|
230
|
-
const decided = (s: RunStep): boolean => s.result !== ISOLATED_RESULT.notRun && !(s.code === ISOLATED_CODE.silent && s.figures?.midiInputsLinked === undefined);
|
|
231
|
-
if (!owesIsolated(record) || record.run?.steps.some((s) => isStep(s) && decided(s)) === true) return undefined;
|
|
244
|
+
if (!owesIsolated(record) || isolatedFinished(record)) return undefined;
|
|
232
245
|
const id = runIdOf(record, join(path, ".."));
|
|
233
246
|
if (id === undefined) return undefined;
|
|
234
247
|
const step = judgeIsolatedRun(runner(uri, cell, env));
|
package/src/qualify-pool.ts
CHANGED
|
@@ -30,6 +30,7 @@ import { existsSync } from "node:fs";
|
|
|
30
30
|
import { availableParallelism } from "node:os";
|
|
31
31
|
import { join } from "node:path";
|
|
32
32
|
|
|
33
|
+
import { withRunHome } from "./run-home.js";
|
|
33
34
|
import type { GridCell } from "./measurement-grid.js";
|
|
34
35
|
import type { PinEntry, PinPool } from "./pins-file.js";
|
|
35
36
|
import type { PoolFigures, QualificationRecord, RunBlock, RunStep } from "./qualification-record.js";
|
|
@@ -203,7 +204,7 @@ const POOL_STEP_SLACK_MS = 120000;
|
|
|
203
204
|
export function asksForPool(prober: string, uri: string, env: Readonly<Record<string, string | undefined>>): boolean | undefined {
|
|
204
205
|
const m = /^clap:(.*)#([^#]*)$/s.exec(uri);
|
|
205
206
|
if (m === null) return undefined;
|
|
206
|
-
const r = spawnSync(prober, ["--asks-thread-pool", m[1]!, m[2]!], { encoding: "utf8", timeout: 60000, killSignal: "SIGKILL", env:
|
|
207
|
+
const r = spawnSync(prober, ["--asks-thread-pool", m[1]!, m[2]!], { encoding: "utf8", timeout: 60000, killSignal: "SIGKILL", env: withRunHome(env) });
|
|
207
208
|
if (r.status !== 0) return undefined;
|
|
208
209
|
try {
|
|
209
210
|
return (JSON.parse(r.stdout) as { threadPool?: unknown }).threadPool === true;
|
|
@@ -233,7 +234,7 @@ export function watchOnHelpers(prober: string, interposer: string, uri: string,
|
|
|
233
234
|
encoding: "utf8",
|
|
234
235
|
timeout: 120000,
|
|
235
236
|
killSignal: "SIGKILL",
|
|
236
|
-
env: { ...env, LD_PRELOAD: preload },
|
|
237
|
+
env: { ...withRunHome(env), LD_PRELOAD: preload },
|
|
237
238
|
});
|
|
238
239
|
if (r.error !== undefined || r.status !== 0) return { problem: `${CLAP_QUALIFY_TOOL_NAME} --pool: ${r.error?.message ?? `exit ${String(r.status)}`}` };
|
|
239
240
|
const line = r.stdout.split("\n").filter((l) => l.startsWith("{")).pop();
|
|
@@ -273,7 +274,7 @@ export function spawnedPoolRunner(jack: JackStarter = startPrivateJack): PoolRun
|
|
|
273
274
|
encoding: "utf8",
|
|
274
275
|
timeout: 2 * POOL_ROUNDS * POOL_WINDOW_MS + POOL_STEP_SLACK_MS,
|
|
275
276
|
killSignal: "SIGKILL",
|
|
276
|
-
env: { ...env, JACK_DEFAULT_SERVER: server.name, [ISOLATED_SIGNAL_TOOL_ENV]: signal },
|
|
277
|
+
env: { ...withRunHome(env), JACK_DEFAULT_SERVER: server.name, [ISOLATED_SIGNAL_TOOL_ENV]: signal },
|
|
277
278
|
});
|
|
278
279
|
if (r.error !== undefined || r.status !== 0) return { started: false, problem: `pool-step: ${r.error?.message ?? `exit ${String(r.status)}`}: ${(r.stderr ?? "").trim()}` };
|
|
279
280
|
return { ...(JSON.parse(r.stdout) as PoolRunFacts), asks: true, rt: watched.rt };
|
package/src/qualify-rejudge.ts
CHANGED
|
@@ -48,7 +48,7 @@ import {
|
|
|
48
48
|
type RecordVerdict,
|
|
49
49
|
type RunBlock,
|
|
50
50
|
} from "./qualification-record.js";
|
|
51
|
-
import { ISOLATED_RESULT, RUN_STEP,
|
|
51
|
+
import { ISOLATED_RESULT, RUN_STEP, isolatedFinished, pinAfterStep } from "./qualify-isolated.js";
|
|
52
52
|
import { pinAfterPool, poolStepOf } from "./qualify-pool.js";
|
|
53
53
|
import { PI_CELLS, PROBE_MEMORY_ONLY_ENV, PROBE_NOTES_ENV } from "./qualify-declarations.js";
|
|
54
54
|
import { SYNC_STATE_SCHEMA, digestFiles, recordStem, type SyncContext, type SyncEntry, type SyncPlugin, type SyncState } from "./qualify-sync.js";
|
|
@@ -198,9 +198,9 @@ export function rejudgeRecord(record: QualificationRecord, probeBytes: Uint8Arra
|
|
|
198
198
|
const { profiles, kept } = rejudgeProfiles(record, output, options.host);
|
|
199
199
|
const { reasons, owes: runs } = runsOwed(record, output, profiles);
|
|
200
200
|
const judgment = judgeProbeOutput(output, profiles, { admissions: admissionsOf(record), owed: reasons });
|
|
201
|
-
// decides no verdict: a record from before the steps that reads isolated stays offered
|
|
201
|
+
// decides no verdict: a record from before the steps that reads isolated stays offered and runs alone, and owes the step until one has finished; a refusal is finished: it is the step's verdict, not debt
|
|
202
202
|
const readsIsolated = judgment.hosts.some((h) => h.verdict.measured && h.verdict.rating === "conditional");
|
|
203
|
-
const owes = readsIsolated && !
|
|
203
|
+
const owes = readsIsolated && !isolatedFinished(record) ? [...runs, { measurement: "isolated-step" as const, code: OWED_CODE.isolatedStep }] : runs;
|
|
204
204
|
const memory = record.memoryRun === undefined ? {} : { memory: { tool: record.memoryRun.tool, measuredAt: record.memoryRun.measuredAt } };
|
|
205
205
|
const a = record.adapter;
|
|
206
206
|
const adapterTaken = a?.versions === undefined || a.measuredAt === undefined ? {} : { adapter: { versions: a.versions, measuredAt: a.measuredAt } };
|
package/src/qualify-sync.ts
CHANGED
|
@@ -23,15 +23,19 @@ import { createConnection } from "node:net";
|
|
|
23
23
|
import { homedir, tmpdir } from "node:os";
|
|
24
24
|
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
25
25
|
|
|
26
|
+
import { withRunHome } from "./run-home.js";
|
|
26
27
|
import { clapQualifyTools } from "./clap-qualify.js";
|
|
27
28
|
import { DISTRIBUTED_DIR_SYSTEM, EMPTY_LAYER, applyDistributed, distributedUri, hostClassOfBoard, hostClassOfMachine, outcomeOfVerdict, matchDistributed, readBoardModel, readMachine, readDistributedLayer, type DistributedLayer } from "./distributed-records.js";
|
|
28
|
-
import {
|
|
29
|
+
import { parseClapScan } from "./clap-loader.js";
|
|
30
|
+
import { SPAWNED_SEARCH_PATH, hostdSearchPath, searchPathWarnings, type SearchPathRun } from "./hostd-search-path.js";
|
|
29
31
|
import { HOSTING_CODE } from "./hosting-codes.js";
|
|
30
32
|
import { shippedHostProfile, type HostProfile } from "./host-profiles.js";
|
|
31
|
-
import {
|
|
33
|
+
import { NO_JACK_SERVER } from "./host-admission.js";
|
|
34
|
+
import { iriPath, lv2BundlesOnPath, manifestPlugins } from "./lv2-loader.js";
|
|
32
35
|
import { judgmentPinEntries, pinClearLine, pinLineUri, pinsFile, type PinEntry } from "./pins-file.js";
|
|
33
36
|
import { POOL_RESULT, completePool, judgePoolRun, pinAfterPool, pinFromRecordWithPool, poolEnv, spawnedPoolRunner, type PoolRunner } from "./qualify-pool.js";
|
|
34
37
|
import { hostdPinFiles, type PinFilesRun } from "./hostd-pin-files.js";
|
|
38
|
+
import { localeOf, reasonText } from "./text-catalog.js";
|
|
35
39
|
import type { Judgment } from "./probe-judge.js";
|
|
36
40
|
import { qualifyTargets, type ProbeRun } from "./probe-runner.js";
|
|
37
41
|
import { GRID_FULL, hostCells, parseGridSpec, type GridCell, type GridSpec } from "./measurement-grid.js";
|
|
@@ -39,7 +43,7 @@ import { adapterPass, type AdapterPassOptions, type AdapterPassResult } from "./
|
|
|
39
43
|
import { ISOLATED_RESULT, baseSteps, completeIsolated, isolatedCell, missingSteps, judgeIsolatedRun, owesIsolated, pinAfterStep, spawnedIsolatedRunner, type IsolatedRunner } from "./qualify-isolated.js";
|
|
40
44
|
import { lv2World, rejudgeRecord, rejudgeState, rejudgeToolName, memoryPass, type MemoryPassResult, type RejudgeResult } from "./qualify-rejudge.js";
|
|
41
45
|
import { memoryBoundOf, parseMemoryLimit, type MemoryBound } from "./memory-bound.js";
|
|
42
|
-
import { QUALIFICATION_RECORD_SCHEMA, latencyNeverTaken, rewriteRecord, writeQualification, type NotMeasured, type QualificationRecord } from "./qualification-record.js";
|
|
46
|
+
import { QUALIFICATION_RECORD_SCHEMA, descriptorNamePart, latencyNeverTaken, rewriteRecord, writeQualification, type NotMeasured, type QualificationRecord } from "./qualification-record.js";
|
|
43
47
|
import { ADMIT_STEP_MS_ENV, DESCRIPTOR_DIR_STATE_SUBDIR, DESCRIPTOR_DIR_SYSTEM, MEMORY_PASS_PROBE_NOTES, QUALIFY_QUANTA, QUALIFY_RATES } from "./qualify-declarations.js";
|
|
44
48
|
import { toolsDir } from "./tool-paths.js";
|
|
45
49
|
import { writeFileWhole } from "./whole-file.js";
|
|
@@ -49,12 +53,6 @@ import { writeFileWhole } from "./whole-file.js";
|
|
|
49
53
|
/** The settings file a system service reads, `key value` per line. */
|
|
50
54
|
export const SYNC_CONF = "/etc/omx/qualify.conf";
|
|
51
55
|
|
|
52
|
-
/** The LV2 directories plugin-hostd searches when `LV2_PATH` is unset (its `lv2_path` default). */
|
|
53
|
-
export const HOSTD_LV2_PATH = ["/usr/lib64/lv2", "/usr/lib/lv2", "/usr/local/lib/lv2"];
|
|
54
|
-
|
|
55
|
-
/** Zynthian's own LV2 directories: the image's plugins and the ones its user installs. */
|
|
56
|
-
export const ZYNTHIAN_LV2_DIRS = ["/zynthian/zynthian-plugins/lv2", "/zynthian/zynthian-my-data/plugins/lv2"];
|
|
57
|
-
|
|
58
56
|
export interface SyncSetting {
|
|
59
57
|
readonly key: string;
|
|
60
58
|
readonly meaning: string;
|
|
@@ -64,10 +62,7 @@ export interface SyncSetting {
|
|
|
64
62
|
|
|
65
63
|
/** Every setting, declared once: the settings file, the flags (`--pin-file` for `pin_file`) and `--help` read this. */
|
|
66
64
|
export const SYNC_SETTINGS: readonly SyncSetting[] = [
|
|
67
|
-
{ key: "
|
|
68
|
-
{ key: "lv2_extra", meaning: "more LV2 directories, searched after lv2_path", default: ZYNTHIAN_LV2_DIRS.join(":") },
|
|
69
|
-
{ key: "clap_path", meaning: "the CLAP directories, colon-separated", default: "$CLAP_PATH, then ~/.clap, then /usr/lib/clap" },
|
|
70
|
-
{ key: "clap_extra", meaning: "more CLAP directories, searched after clap_path", default: "" },
|
|
65
|
+
{ key: "hostd_conf", meaning: "plugin-hostd's settings file: the LV2 and CLAP directories scanned are the ones plugin-hostd --search-path -c <file> prints", default: "plugin-hostd's own (its XDG_CONFIG_HOME/plugin-hostd.conf, else ~/.config/plugin-hostd.conf)" },
|
|
71
66
|
{ key: "pin_file", meaning: "the pin file plugin-hostd loads as pin_file", default: "/var/lib/omx/qualified.pins (system), else $XDG_STATE_HOME/omx/qualified.pins" },
|
|
72
67
|
{ key: "qualification_dir", meaning: "where the qualification records go", default: "/var/lib/omx/qualification (system), else $XDG_STATE_HOME/omx/qualification" },
|
|
73
68
|
{ key: "descriptor_dir", meaning: "where the scanned descriptors go", default: `${DESCRIPTOR_DIR_SYSTEM} (system), else $XDG_STATE_HOME/${DESCRIPTOR_DIR_STATE_SUBDIR}` },
|
|
@@ -85,8 +80,16 @@ export const SYNC_SETTINGS: readonly SyncSetting[] = [
|
|
|
85
80
|
{ key: "distributed_dir", meaning: "the directories of distributed records, colon-separated, each holding <host class>/<set>/", default: `${DISTRIBUTED_DIR_SYSTEM} (system), else $XDG_DATA_HOME/omx/qualification and each $XDG_DATA_DIRS/omx/qualification` },
|
|
86
81
|
];
|
|
87
82
|
|
|
83
|
+
/** The search path settings that moved to plugin-hostd's settings file (plugin-qualify#9): refused here, with where they went. */
|
|
84
|
+
export const MOVED_TO_HOSTD = ["lv2_path", "lv2_extra", "clap_path", "clap_extra"] as const;
|
|
85
|
+
|
|
86
|
+
const movedText = (key: string): string =>
|
|
87
|
+
`${key} is plugin-hostd's setting now: write it in plugin-hostd's settings file (plugin-hostd.conf, named here by hostd_conf); the directories scanned are the ones plugin-hostd --search-path prints${key === "clap_extra" ? " (plugin-hostd has no clap_extra: add the directories to clap_path)" : ""}`;
|
|
88
|
+
|
|
88
89
|
export interface SyncSettings {
|
|
90
|
+
/** The LV2 directories, as plugin-hostd searches them with hostd_conf (plugin-hostd --search-path). */
|
|
89
91
|
readonly lv2Dirs: readonly string[];
|
|
92
|
+
/** The CLAP directories, as plugin-hostd searches them with hostd_conf. */
|
|
90
93
|
readonly clapDirs: readonly string[];
|
|
91
94
|
readonly pinFile: string;
|
|
92
95
|
readonly qualificationDir: string;
|
|
@@ -121,6 +124,7 @@ export function parseSyncConf(text: string, source = SYNC_CONF): { ok: true; val
|
|
|
121
124
|
if (line === "") continue;
|
|
122
125
|
const m = /^(\S+)(?:\s+(.*))?$/.exec(line)!;
|
|
123
126
|
const key = m[1]!;
|
|
127
|
+
if ((MOVED_TO_HOSTD as readonly string[]).includes(key)) return { ok: false, error: `${source}:${String(n + 1)}: ${movedText(key)}` };
|
|
124
128
|
if (!known.has(key)) return { ok: false, error: `${source}:${String(n + 1)}: ${key} is not a setting` };
|
|
125
129
|
values.set(key, (m[2] ?? "").trim());
|
|
126
130
|
}
|
|
@@ -186,7 +190,8 @@ export function parseSyncArgs(argv: readonly string[]): { ok: true; args: SyncAr
|
|
|
186
190
|
if (v === undefined) return { ok: false, error: `${a} needs a value` };
|
|
187
191
|
if (a === "--config") config = v;
|
|
188
192
|
else values.set(byFlag.get(a)!, v);
|
|
189
|
-
} else return { ok: false, error:
|
|
193
|
+
} else if (MOVED_TO_HOSTD.some((k) => flagOf(k) === a)) return { ok: false, error: `${a}: ${movedText(a.slice(2).replace(/-/g, "_"))}` };
|
|
194
|
+
else return { ok: false, error: `unknown argument ${a}` };
|
|
190
195
|
}
|
|
191
196
|
if (rejudge && (memoryOnly || adapterOnly || isolatedOnly || poolOnly || force || refreshPinsOnly)) return { ok: false, error: "--rejudge measures nothing: it goes with none of --memory-only, --adapter-only, --isolated-only, --pool-only, --force and --refresh-pins" };
|
|
192
197
|
if ([memoryOnly, adapterOnly, isolatedOnly, poolOnly, refreshPinsOnly].filter(Boolean).length > 1) return { ok: false, error: "--memory-only, --adapter-only, --isolated-only, --pool-only and --refresh-pins are different runs: one at a time" };
|
|
@@ -229,17 +234,18 @@ export function resolveSyncSettings(
|
|
|
229
234
|
home: string = homedir(),
|
|
230
235
|
boardModel: () => string | undefined = readBoardModel,
|
|
231
236
|
machineArch: () => string | undefined = readMachine,
|
|
232
|
-
|
|
237
|
+
searchPathRun: SearchPathRun = SPAWNED_SEARCH_PATH,
|
|
238
|
+
): { ok: true; settings: SyncSettings; warnings: string[] } | { ok: false; error: string } {
|
|
233
239
|
const get = (key: string): string | undefined => args.values.get(key) ?? file.get(key);
|
|
234
240
|
const state = env["XDG_STATE_HOME"] !== undefined && env["XDG_STATE_HOME"] !== "" ? env["XDG_STATE_HOME"] : join(home, ".local", "state");
|
|
235
241
|
const place = (key: string, system: string, user: string): string => {
|
|
236
242
|
const v = get(key);
|
|
237
243
|
return v !== undefined && v !== "" ? v : args.system ? system : join(state, user);
|
|
238
244
|
};
|
|
239
|
-
const
|
|
240
|
-
const
|
|
241
|
-
const
|
|
242
|
-
|
|
245
|
+
const hostdConfNamed = get("hostd_conf");
|
|
246
|
+
const hostdConf = hostdConfNamed !== undefined && hostdConfNamed !== "" ? hostdConfNamed : undefined;
|
|
247
|
+
const search = hostdSearchPath(hostdConf, env, searchPathRun);
|
|
248
|
+
if (!search.ok) return { ok: false, error: `the directories to scan: ${search.error}` };
|
|
243
249
|
const number = (key: string, min: number): number | undefined => {
|
|
244
250
|
const v = Number(get(key) ?? SYNC_SETTINGS.find((s) => s.key === key)!.default);
|
|
245
251
|
return Number.isFinite(v) && v >= min ? v : undefined;
|
|
@@ -281,9 +287,10 @@ export function resolveSyncSettings(
|
|
|
281
287
|
if (hostClass !== undefined && !/^[A-Za-z0-9][A-Za-z0-9._+-]*$/.test(hostClass)) return { ok: false, error: `host_class ${hostClass} is not a directory name` };
|
|
282
288
|
return {
|
|
283
289
|
ok: true,
|
|
290
|
+
warnings: searchPathWarnings(search.path, hostdConf),
|
|
284
291
|
settings: {
|
|
285
|
-
lv2Dirs: once(
|
|
286
|
-
clapDirs: once(
|
|
292
|
+
lv2Dirs: once(search.path.lv2),
|
|
293
|
+
clapDirs: once(search.path.clap),
|
|
287
294
|
pinFile: place("pin_file", "/var/lib/omx/qualified.pins", join("omx", "qualified.pins")),
|
|
288
295
|
qualificationDir: place("qualification_dir", "/var/lib/omx/qualification", join("omx", "qualification")),
|
|
289
296
|
descriptorDir: place("descriptor_dir", DESCRIPTOR_DIR_SYSTEM, DESCRIPTOR_DIR_STATE_SUBDIR),
|
|
@@ -442,7 +449,7 @@ export function discoverSyncItems(
|
|
|
442
449
|
const binary = lilv?.binary ?? (p.binary === undefined ? undefined : join(bundle, p.binary));
|
|
443
450
|
const files = ["manifest.ttl", ...p.files.filter((f) => f !== "manifest.ttl")];
|
|
444
451
|
// a binary named by an absolute IRI (a complement's) is tracked through the bundle's link to it, when it has one
|
|
445
|
-
if (binary !== undefined && !files.
|
|
452
|
+
if (binary !== undefined && !files.some((f) => iriPath(f) === basename(binary)) && linksTo(join(bundle, basename(binary)), binary)) files.push(basename(binary));
|
|
446
453
|
items.push({ key: `lv2:${p.uri}`, format: "lv2", root: bundle, files, uri: p.uri, ...(binary === undefined ? {} : { binary }) });
|
|
447
454
|
}
|
|
448
455
|
}
|
|
@@ -482,7 +489,10 @@ export function fileSha256(path: string): string {
|
|
|
482
489
|
export function digestFiles(root: string, files: readonly string[], known: readonly FileDigest[] = []): FileDigest[] | undefined {
|
|
483
490
|
const out: FileDigest[] = [];
|
|
484
491
|
for (const path of [...new Set(files)].sort()) {
|
|
485
|
-
|
|
492
|
+
// a name may be an IRI's word (the pin keeps `libSurge%20XT.so`); the file is the path it decodes to, and a name
|
|
493
|
+
// that is a file as written (a .clap with a % in it) is read as written when the decoded one is not there
|
|
494
|
+
const decoded = join(root, iriPath(path));
|
|
495
|
+
const full = existsSync(decoded) ? decoded : join(root, path);
|
|
486
496
|
try {
|
|
487
497
|
const st = statSync(full);
|
|
488
498
|
if (!st.isFile()) return undefined;
|
|
@@ -526,6 +536,7 @@ export function machineReason(p: Pick<SyncPlugin, "outcome" | "code" | "detail">
|
|
|
526
536
|
if (p.outcome !== "unmeasured" || p.code !== SYNC_CODE.notMeasured) return undefined;
|
|
527
537
|
const errno = /\b(EACCES|EPERM|ENOSPC)\b/.exec(p.detail ?? "")?.[1];
|
|
528
538
|
if (errno !== undefined) return errno;
|
|
539
|
+
if ((p.detail ?? "").startsWith(NO_JACK_SERVER)) return NO_JACK_SERVER;
|
|
529
540
|
return /wrote no document|cannot run the probe runner/.test(p.detail ?? "") ? "the measurement wrote no document" : undefined;
|
|
530
541
|
}
|
|
531
542
|
|
|
@@ -623,7 +634,7 @@ export function recordedAsIs(item: SyncItem, entry: SyncEntry | undefined): { sa
|
|
|
623
634
|
*/
|
|
624
635
|
export function sameBinaryElsewhere(item: SyncItem, entry: SyncEntry | undefined): SyncEntry | undefined {
|
|
625
636
|
if (item.format !== "lv2" || entry === undefined || item.binary === undefined || resolve(entry.root) === resolve(item.root)) return undefined;
|
|
626
|
-
const was = entry.files.find((f) => basename(f.path) === basename(item.binary!));
|
|
637
|
+
const was = entry.files.find((f) => basename(iriPath(f.path)) === basename(item.binary!));
|
|
627
638
|
if (was === undefined) return undefined;
|
|
628
639
|
let now: string;
|
|
629
640
|
try {
|
|
@@ -660,7 +671,7 @@ function withHostdFiles(ctx: SyncContext, pin: PinEntry | undefined): PinEntry |
|
|
|
660
671
|
if (pin === undefined || pin.integrity.files === undefined) return pin;
|
|
661
672
|
const r = hostdPinFiles(pin.uri, ctx.settings.lv2Dirs, ctx.env, ...(ctx.pinFilesRun === undefined ? [] : [ctx.pinFilesRun]));
|
|
662
673
|
if (!r.ok) {
|
|
663
|
-
ctx.log(`${pin.uri}: not pinned: ${r.detail}`);
|
|
674
|
+
ctx.log(`${pin.uri}: not pinned: ${reasonText(r.detail, localeOf(ctx.env))}`);
|
|
664
675
|
return undefined;
|
|
665
676
|
}
|
|
666
677
|
return { ...pin, integrity: { ...pin.integrity, files: r.files } };
|
|
@@ -683,7 +694,7 @@ export function refreshPins(ctx: SyncContext, state: SyncState): { state: SyncSt
|
|
|
683
694
|
if (pin === undefined || pin.integrity.files === undefined) return plugin;
|
|
684
695
|
const r = hostdPinFiles(pin.uri, ctx.settings.lv2Dirs, ctx.env, ...(ctx.pinFilesRun === undefined ? [] : [ctx.pinFilesRun]));
|
|
685
696
|
if (!r.ok) {
|
|
686
|
-
ctx.log(`${pin.uri}: pin files not refreshed: ${r.detail}`);
|
|
697
|
+
ctx.log(`${pin.uri}: pin files not refreshed: ${reasonText(r.detail, localeOf(ctx.env))}`);
|
|
687
698
|
return plugin;
|
|
688
699
|
}
|
|
689
700
|
const old = new Map(pin.integrity.files.map((f) => [f.path, f.sha256]));
|
|
@@ -715,7 +726,7 @@ function outcomeOf(judgment: Judgment, host: string): { outcome: SyncOutcome; co
|
|
|
715
726
|
return { outcome: v.rating === "unsuitable" ? "refused" : "unmeasured", ...(code === undefined ? {} : { code }) };
|
|
716
727
|
}
|
|
717
728
|
|
|
718
|
-
const safe =
|
|
729
|
+
const safe = descriptorNamePart;
|
|
719
730
|
|
|
720
731
|
/** The record of a plugin no probe output exists for: what it is, the bytes it was, and why nothing was measured. */
|
|
721
732
|
function writeUnmeasuredRecord(ctx: SyncContext, item: SyncItem, files: readonly FileDigest[], plugin: Omit<SyncPlugin, "record">): string {
|
|
@@ -735,8 +746,19 @@ function writeUnmeasuredRecord(ctx: SyncContext, item: SyncItem, files: readonly
|
|
|
735
746
|
return path;
|
|
736
747
|
}
|
|
737
748
|
|
|
738
|
-
/** The step
|
|
739
|
-
|
|
749
|
+
/** The directories the sync scanned, as the plugin-hostd a step starts reads them: with neither variable set it searches
|
|
750
|
+
* only its own defaults, and every plugin of `lv2_extra` or of a directory the settings add is refused at add. */
|
|
751
|
+
export const hostdSearchEnv = (settings: Pick<SyncSettings, "lv2Dirs" | "clapDirs">): Record<string, string> => ({
|
|
752
|
+
LV2_PATH: settings.lv2Dirs.join(":"),
|
|
753
|
+
CLAP_PATH: settings.clapDirs.join(":"),
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
/** The step's environment: the sync's directories, and each of its commands has the sync's budget. */
|
|
757
|
+
export const isolatedEnv = (ctx: Pick<SyncContext, "env" | "settings">): Readonly<Record<string, string | undefined>> => ({
|
|
758
|
+
...ctx.env,
|
|
759
|
+
...hostdSearchEnv(ctx.settings),
|
|
760
|
+
[ADMIT_STEP_MS_ENV]: String(ctx.settings.budgetS * 1000),
|
|
761
|
+
});
|
|
740
762
|
|
|
741
763
|
/** Each judged run of a pass as the plugins it qualified: the record written, the pin taken, the outcome. */
|
|
742
764
|
function judgedPlugins(ctx: SyncContext, runs: readonly { bytes: Buffer; judgment: Judgment; stderr?: readonly Buffer[] }[], describeProblem?: string): SyncPlugin[] {
|
|
@@ -772,7 +794,7 @@ function judgedPlugins(ctx: SyncContext, runs: readonly { bytes: Buffer; judgmen
|
|
|
772
794
|
// the pool is for a CLAP plugin that runs: the thread pool is granted by measuring it, here and nowhere else
|
|
773
795
|
if (judgment.output.plugin.format === "clap" && pin !== undefined && pin.verdict !== "refused" && outcome === "qualified") {
|
|
774
796
|
const runner = ctx.poolRunner ?? spawnedPoolRunner();
|
|
775
|
-
const step = judgePoolRun(runner(pin.uri, isolatedCell(record, host), poolEnv(ctx.env, ctx.settings.budgetS)));
|
|
797
|
+
const step = judgePoolRun(runner(pin.uri, isolatedCell(record, host), poolEnv({ ...ctx.env, ...hostdSearchEnv(ctx.settings) }, ctx.settings.budgetS)));
|
|
776
798
|
steps.push(step);
|
|
777
799
|
pin = pinAfterPool(pin, step);
|
|
778
800
|
}
|
|
@@ -845,7 +867,7 @@ function qualifyClap(ctx: SyncContext, item: SyncItem): SyncPlugin[] {
|
|
|
845
867
|
return [{ uri: fileUri, outcome: "unmeasured", code: SYNC_CODE.notMeasured, detail: (e as Error).message }];
|
|
846
868
|
}
|
|
847
869
|
// the scanner lists the plugins of the file in a child of its own, and says how that child ended
|
|
848
|
-
const r = spawnSync(tool, ["--json", file], { encoding: "utf8", timeout: Math.max(ctx.settings.budgetS, 35) * 1000, killSignal: "SIGKILL", maxBuffer: 64 << 20 });
|
|
870
|
+
const r = spawnSync(tool, ["--json", file], { encoding: "utf8", timeout: Math.max(ctx.settings.budgetS, 35) * 1000, killSignal: "SIGKILL", maxBuffer: 64 << 20, env: withRunHome(process.env) });
|
|
849
871
|
let scanned;
|
|
850
872
|
try {
|
|
851
873
|
if (r.error !== undefined) throw r.error;
|
|
@@ -975,7 +997,7 @@ export function poolPass(ctx: SyncContext, state: SyncState): { readonly state:
|
|
|
975
997
|
ctx.log(`${p.uri}: its record does not read; no pool step`);
|
|
976
998
|
return p;
|
|
977
999
|
}
|
|
978
|
-
const done = completePool(path, record, p.pin.uri, isolatedCell(record, host), runner, poolEnv(ctx.env, ctx.settings.budgetS));
|
|
1000
|
+
const done = completePool(path, record, p.pin.uri, isolatedCell(record, host), runner, poolEnv({ ...ctx.env, ...hostdSearchEnv(ctx.settings) }, ctx.settings.budgetS));
|
|
979
1001
|
if (done === undefined) return p;
|
|
980
1002
|
ctx.log(`${p.uri}: pool step ${done.step.result}${done.step.code === undefined ? "" : ` (${done.step.code})`}`);
|
|
981
1003
|
if (done.step.result === POOL_RESULT.cores) count.cores++;
|
|
@@ -1079,7 +1101,7 @@ function scanClapDescriptors(ctx: SyncContext, item: SyncItem): void {
|
|
|
1079
1101
|
ctx.log(`${file}: descriptors not written: ${(e as Error).message}`);
|
|
1080
1102
|
return;
|
|
1081
1103
|
}
|
|
1082
|
-
const r = spawnSync(tool, ["--json", file], { encoding: "utf8", timeout: Math.max(ctx.settings.budgetS, 35) * 1000, killSignal: "SIGKILL", maxBuffer: 64 << 20 });
|
|
1104
|
+
const r = spawnSync(tool, ["--json", file], { encoding: "utf8", timeout: Math.max(ctx.settings.budgetS, 35) * 1000, killSignal: "SIGKILL", maxBuffer: 64 << 20, env: withRunHome(process.env) });
|
|
1083
1105
|
if (r.error !== undefined || r.status !== 0) {
|
|
1084
1106
|
ctx.log(`${file}: descriptors not written: the scan ended ${r.error?.message ?? `with exit ${String(r.status)}`}`);
|
|
1085
1107
|
return;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
// Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
|
|
3
|
+
/**
|
|
4
|
+
* The figures a qualification record holds at one rate and quantum: what plugin-qualify's own
|
|
5
|
+
* menus and the zynthian-ui fork both need to show, read from `plugin-qualify.qualification-record/1`
|
|
6
|
+
* (`qualification-record.ts`) rather than re-derived.
|
|
7
|
+
*
|
|
8
|
+
* `tools/record_figures.py` is the same reader for a Python console (zynthian-ui): the two are
|
|
9
|
+
* held equal over the same fixtures (`record-figures-python-conformance.test.ts`), figure for
|
|
10
|
+
* figure, word for word and code for code.
|
|
11
|
+
*
|
|
12
|
+
* This reader takes no side on the plugin: no tier, no state, no verdict. A figure plugin-qualify
|
|
13
|
+
* did not take is absent here, never a zero and never guessed — that is plugin-db's reader
|
|
14
|
+
* (plugin-db#4) and the tiers already in the record (`RecordTiers`).
|
|
15
|
+
*/
|
|
16
|
+
import type { QualificationRecord } from "./qualification-record.js";
|
|
17
|
+
|
|
18
|
+
/** The latency frames this cell holds: what the plugin declared, and what the run measured, when either was taken. */
|
|
19
|
+
export interface RecordLatencyFigures {
|
|
20
|
+
readonly declaredFrames?: number;
|
|
21
|
+
readonly measuredFrames?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The interposer's RT-safety counts for this cell, as the probe wrote them — never re-summed. */
|
|
25
|
+
export interface RecordRtSafetyFigures {
|
|
26
|
+
/** The interposer was loaded; a count below is zero from a real run, not from no interposer. */
|
|
27
|
+
readonly interposer?: boolean;
|
|
28
|
+
readonly swept?: boolean;
|
|
29
|
+
readonly sweepApplicable?: boolean;
|
|
30
|
+
readonly allocations?: number;
|
|
31
|
+
readonly locks?: number;
|
|
32
|
+
readonly syscalls?: number;
|
|
33
|
+
readonly threadViolations?: number;
|
|
34
|
+
readonly processErrors?: number;
|
|
35
|
+
/** How many events the interposer logged (`malloc ? ?`, ...); the words themselves are for a log, not this reader. */
|
|
36
|
+
readonly events?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The figures of one qualification record at one rate and quantum. */
|
|
40
|
+
export interface RecordFigures {
|
|
41
|
+
readonly latency?: RecordLatencyFigures;
|
|
42
|
+
/** The median cost of this cell, in nanoseconds per sample (`ProbeCost.median`'s own unit). */
|
|
43
|
+
readonly costNsPerSampleMedian?: number;
|
|
44
|
+
readonly rtSafety?: RecordRtSafetyFigures;
|
|
45
|
+
/** Whether the continuous soak ran at this rate; `false` when the record never names a soak reading here. */
|
|
46
|
+
readonly soakRan: boolean;
|
|
47
|
+
/** This cell's own outcome code, carried verbatim, exactly when it is a crash. */
|
|
48
|
+
readonly crashCode?: string;
|
|
49
|
+
/** This cell's own outcome code, carried verbatim, exactly when it is a supervisor timeout. */
|
|
50
|
+
readonly timeoutCode?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const PROBE_CRASHED = "probe.crashed";
|
|
54
|
+
const PROBE_TIMEOUT = "probe.timeout";
|
|
55
|
+
|
|
56
|
+
function rtSafetyFiguresOf(rtSafety: QualificationRecord["measurements"]["grid"][number]["rtSafety"]): RecordRtSafetyFigures | undefined {
|
|
57
|
+
if (rtSafety === undefined) return undefined;
|
|
58
|
+
return {
|
|
59
|
+
...(rtSafety.interposer === undefined ? {} : { interposer: rtSafety.interposer }),
|
|
60
|
+
...(rtSafety.swept === undefined ? {} : { swept: rtSafety.swept }),
|
|
61
|
+
...(rtSafety.sweepApplicable === undefined ? {} : { sweepApplicable: rtSafety.sweepApplicable }),
|
|
62
|
+
...(rtSafety.allocations === undefined ? {} : { allocations: rtSafety.allocations }),
|
|
63
|
+
...(rtSafety.locks === undefined ? {} : { locks: rtSafety.locks }),
|
|
64
|
+
...(rtSafety.syscalls === undefined ? {} : { syscalls: rtSafety.syscalls }),
|
|
65
|
+
...(rtSafety.threadViolations === undefined ? {} : { threadViolations: rtSafety.threadViolations }),
|
|
66
|
+
...(rtSafety.processErrors === undefined ? {} : { processErrors: rtSafety.processErrors }),
|
|
67
|
+
...(rtSafety.events === undefined ? {} : { events: rtSafety.events.length }),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function latencyFiguresOf(latency: QualificationRecord["measurements"]["grid"][number]["latency"]): RecordLatencyFigures | undefined {
|
|
72
|
+
const declaredFrames = (latency as { declaredFrames?: number } | undefined)?.declaredFrames;
|
|
73
|
+
const measuredFrames = (latency as { measuredFrames?: number } | undefined)?.measuredFrames;
|
|
74
|
+
if (declaredFrames === undefined && measuredFrames === undefined) return undefined;
|
|
75
|
+
return {
|
|
76
|
+
...(declaredFrames === undefined ? {} : { declaredFrames }),
|
|
77
|
+
...(measuredFrames === undefined ? {} : { measuredFrames }),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Whether the record names a continuous soak reading at this rate (`measurements.behaviour.soak.perRate`). */
|
|
82
|
+
function soakRanAt(record: QualificationRecord, rate: number): boolean {
|
|
83
|
+
const soak = record.measurements.behaviour?.soak as { readonly perRate?: Readonly<Record<string, unknown>> } | undefined;
|
|
84
|
+
return soak?.perRate?.[String(rate)] !== undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The figures `plugin-qualify.qualification-record/1` holds at one rate and quantum: the cell the
|
|
89
|
+
* record's grid names at that pair, else every per-cell figure is absent and `soakRan` alone still
|
|
90
|
+
* answers for the rate.
|
|
91
|
+
*/
|
|
92
|
+
export function recordFigures(record: QualificationRecord, rate: number, quantum: number): RecordFigures {
|
|
93
|
+
const point = record.measurements.grid.find((p) => p.rate === rate && p.quantum === quantum);
|
|
94
|
+
return {
|
|
95
|
+
...(point === undefined ? {} : { ...wrapLatency(point.latency), ...wrapCost(point.cost), ...wrapRtSafety(point.rtSafety) }),
|
|
96
|
+
soakRan: soakRanAt(record, rate),
|
|
97
|
+
...(point?.outcome === PROBE_CRASHED ? { crashCode: point.outcome } : {}),
|
|
98
|
+
...(point?.outcome === PROBE_TIMEOUT ? { timeoutCode: point.outcome } : {}),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function wrapLatency(latency: QualificationRecord["measurements"]["grid"][number]["latency"]): Pick<RecordFigures, "latency"> | Record<string, never> {
|
|
103
|
+
const l = latencyFiguresOf(latency);
|
|
104
|
+
return l === undefined ? {} : { latency: l };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function wrapCost(cost: QualificationRecord["measurements"]["grid"][number]["cost"]): Pick<RecordFigures, "costNsPerSampleMedian"> | Record<string, never> {
|
|
108
|
+
return cost === undefined ? {} : { costNsPerSampleMedian: cost.median };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function wrapRtSafety(rtSafety: QualificationRecord["measurements"]["grid"][number]["rtSafety"]): Pick<RecordFigures, "rtSafety"> | Record<string, never> {
|
|
112
|
+
const rt = rtSafetyFiguresOf(rtSafety);
|
|
113
|
+
return rt === undefined ? {} : { rtSafety: rt };
|
|
114
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
// Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
|
|
3
|
+
/**
|
|
4
|
+
* #6's conformance check: a refused row's cause must be readable from the record directory itself,
|
|
5
|
+
* not summed to a byte count in the probe copy's `logs` entry. #311 copies a child's stderr beside
|
|
6
|
+
* its record (`stderrCopyFileName`) so the bytes `logs` counts can still be read back; this walks a
|
|
7
|
+
* records directory and names every refused row where that reading still fails: the count is zero
|
|
8
|
+
* (nothing was ever captured) or the count is positive but no copy answers it (the count was kept,
|
|
9
|
+
* the words were not).
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { readProbeOutput, sha256Hex, PROBE_OK } from "./probe-output.js";
|
|
14
|
+
import { stderrCopyFileName, type QualificationRecord } from "./qualification-record.js";
|
|
15
|
+
|
|
16
|
+
export type StderrKeptProblem = "empty-stderr" | "log-line-not-kept";
|
|
17
|
+
|
|
18
|
+
export interface StderrKeptFinding {
|
|
19
|
+
readonly record: string;
|
|
20
|
+
readonly plugin: string;
|
|
21
|
+
readonly problem: StderrKeptProblem;
|
|
22
|
+
readonly detail: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface ProbeLogEntry {
|
|
26
|
+
readonly name?: string;
|
|
27
|
+
readonly sha256?: string;
|
|
28
|
+
readonly bytes?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const isRefused = (record: QualificationRecord): boolean => record.probe.outcome !== PROBE_OK || record.verdicts.some((v) => !v.qualified);
|
|
32
|
+
|
|
33
|
+
/** Every `*.qualification.json` in `dir`, refused rows only, with its parsed record. */
|
|
34
|
+
function refusedRecords(dir: string): { readonly file: string; readonly record: QualificationRecord }[] {
|
|
35
|
+
return readdirSync(dir)
|
|
36
|
+
.filter((f) => f.endsWith(".qualification.json"))
|
|
37
|
+
.map((file) => ({ file, record: JSON.parse(readFileSync(join(dir, file), "utf8")) as QualificationRecord }))
|
|
38
|
+
.filter((r) => isRefused(r.record));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Every refused row in `dir` whose child's own words are not readable back from disk: a row whose every run's stderr
|
|
43
|
+
* was never kept, and a row whose `logs` count bytes a copy beside the record does not answer.
|
|
44
|
+
*/
|
|
45
|
+
export function checkStderrKept(dir: string): StderrKeptFinding[] {
|
|
46
|
+
const findings: StderrKeptFinding[] = [];
|
|
47
|
+
for (const { file, record } of refusedRecords(dir)) {
|
|
48
|
+
const probePath = join(dir, record.probe.file);
|
|
49
|
+
if (!existsSync(probePath)) {
|
|
50
|
+
findings.push({ record: file, plugin: record.plugin.id, problem: "empty-stderr", detail: `${record.probe.file}: the probe copy named by the record is not beside it` });
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const probeText = readFileSync(probePath, "utf8");
|
|
54
|
+
const read = readProbeOutput(probeText);
|
|
55
|
+
if (!read.ok) {
|
|
56
|
+
findings.push({ record: file, plugin: record.plugin.id, problem: "empty-stderr", detail: `${record.probe.file}: ${read.problem}` });
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const probeDoc = JSON.parse(probeText) as { readonly logs?: readonly ProbeLogEntry[] };
|
|
60
|
+
const logs: readonly ProbeLogEntry[] = Array.isArray(probeDoc.logs) ? probeDoc.logs : [];
|
|
61
|
+
const written = logs.filter((l) => (l.bytes ?? 0) > 0);
|
|
62
|
+
if (written.length === 0) {
|
|
63
|
+
findings.push({ record: file, plugin: record.plugin.id, problem: "empty-stderr", detail: "refused, and no run's child printed anything on stderr" });
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
for (const entry of written) {
|
|
67
|
+
const name = entry.name ?? "stderr";
|
|
68
|
+
if (entry.sha256 === undefined) {
|
|
69
|
+
findings.push({ record: file, plugin: record.plugin.id, problem: "log-line-not-kept", detail: `${name}: ${String(entry.bytes)} bytes counted, no SHA-256 to find a copy by` });
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const copyPath = join(dir, stderrCopyFileName(read.output, entry.sha256));
|
|
73
|
+
if (!existsSync(copyPath)) {
|
|
74
|
+
findings.push({ record: file, plugin: record.plugin.id, problem: "log-line-not-kept", detail: `${name}: ${String(entry.bytes)} bytes counted, no copy at ${stderrCopyFileName(read.output, entry.sha256)}` });
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const copied = readFileSync(copyPath);
|
|
78
|
+
if (copied.length === 0 || sha256Hex(copied) !== entry.sha256) {
|
|
79
|
+
findings.push({ record: file, plugin: record.plugin.id, problem: "log-line-not-kept", detail: `${name}: the copy at ${stderrCopyFileName(read.output, entry.sha256)} is empty or does not match the count` });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return findings;
|
|
84
|
+
}
|
package/src/run-home.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
// Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
|
|
3
|
+
/**
|
|
4
|
+
* The HOME every process that loads a plugin starts with: a scratch directory of this plugin-qualify run, never the
|
|
5
|
+
* caller's real home. A plugin that reads its user data from HOME (Surge XT) segfaults or stays silent without one,
|
|
6
|
+
* and a probe must not write into a user's home. Created on first use, removed when the process exits.
|
|
7
|
+
*/
|
|
8
|
+
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
let home: string | undefined;
|
|
13
|
+
|
|
14
|
+
/** The run's scratch HOME, made on first use. */
|
|
15
|
+
export function runHome(): string {
|
|
16
|
+
if (home === undefined) {
|
|
17
|
+
const dir = mkdtempSync(join(tmpdir(), "plugin-qualify-home-"));
|
|
18
|
+
process.on("exit", () => rmSync(dir, { recursive: true, force: true }));
|
|
19
|
+
home = join(dir, "home");
|
|
20
|
+
mkdirSync(home);
|
|
21
|
+
}
|
|
22
|
+
return home;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** `env` with HOME set to the run's scratch HOME. */
|
|
26
|
+
export function withRunHome(env: Readonly<Record<string, string | undefined>>): Record<string, string | undefined> {
|
|
27
|
+
return { ...env, HOME: runHome() };
|
|
28
|
+
}
|