@openmixer/plugin-qualify 0.13.1 → 0.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +151 -14
- package/bin/plugin-qualify-sync.mjs +57 -0
- package/bin/plugin-qualify.mjs +66 -15
- package/dist/adjust-cli.d.ts +83 -0
- package/dist/adjust-cli.js +214 -0
- package/dist/adjust-cli.js.map +1 -0
- package/dist/clap-loader.d.ts +3 -3
- package/dist/clap-loader.js +11 -11
- package/dist/clap-loader.js.map +1 -1
- package/dist/history-adjust.d.ts +77 -0
- package/dist/history-adjust.js +99 -0
- package/dist/history-adjust.js.map +1 -0
- package/dist/history-file.d.ts +73 -0
- package/dist/history-file.js +133 -0
- package/dist/history-file.js.map +1 -0
- package/dist/host-admission.d.ts +53 -0
- package/dist/host-admission.js +96 -0
- package/dist/host-admission.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/lv2-loader.d.ts +16 -2
- package/dist/lv2-loader.js +168 -17
- package/dist/lv2-loader.js.map +1 -1
- package/dist/measurement/cli.d.ts +6 -4
- package/dist/measurement/cli.js +12 -19
- package/dist/measurement/cli.js.map +1 -1
- package/dist/pins-file.d.ts +8 -2
- package/dist/pins-file.js +17 -3
- package/dist/pins-file.js.map +1 -1
- package/dist/probe-judge.d.ts +11 -1
- package/dist/probe-judge.js +20 -1
- package/dist/probe-judge.js.map +1 -1
- package/dist/probe-output-fixtures.d.ts +10 -0
- package/dist/probe-output-fixtures.js +86 -0
- package/dist/probe-output-fixtures.js.map +1 -1
- package/dist/probe-output.d.ts +2 -0
- package/dist/probe-output.js +1 -0
- package/dist/probe-output.js.map +1 -1
- package/dist/probe-runner.d.ts +9 -3
- package/dist/probe-runner.js +36 -12
- package/dist/probe-runner.js.map +1 -1
- package/dist/qualification-record.d.ts +109 -3
- package/dist/qualification-record.js +79 -13
- package/dist/qualification-record.js.map +1 -1
- package/dist/qualify-cli.d.ts +0 -1
- package/dist/qualify-cli.js +2 -4
- package/dist/qualify-cli.js.map +1 -1
- package/dist/qualify-declarations.d.ts +26 -0
- package/dist/qualify-declarations.js +26 -0
- package/dist/qualify-declarations.js.map +1 -1
- package/dist/qualify-options.js +1 -2
- package/dist/qualify-options.js.map +1 -1
- package/dist/qualify-sync.d.ts +202 -0
- package/dist/qualify-sync.js +731 -0
- package/dist/qualify-sync.js.map +1 -0
- package/dist/whole-file.d.ts +3 -0
- package/dist/whole-file.js +36 -0
- package/dist/whole-file.js.map +1 -0
- package/fixtures/admission/2026-10-26.json +19 -0
- package/fixtures/clap-scan/fake.json +24 -24
- package/package.json +5 -4
- package/src/adjust-cli.ts +262 -0
- package/src/clap-loader.ts +9 -13
- package/src/history-adjust.ts +156 -0
- package/src/history-file.ts +177 -0
- package/src/host-admission.ts +136 -0
- package/src/index.ts +6 -0
- package/src/lv2-loader.ts +150 -15
- package/src/measurement/cli.ts +11 -17
- package/src/pins-file.ts +17 -3
- package/src/probe-judge.ts +27 -1
- package/src/probe-output-fixtures.ts +86 -0
- package/src/probe-output.ts +3 -0
- package/src/probe-runner.ts +45 -14
- package/src/qualification-record.ts +174 -15
- package/src/qualify-cli.ts +2 -5
- package/src/qualify-declarations.ts +29 -0
- package/src/qualify-options.ts +1 -2
- package/src/qualify-sync.ts +830 -0
- package/src/whole-file.ts +35 -0
- package/tools/_declarations.py +11 -0
- package/tools/audit-overlap.sh +250 -0
- package/tools/descriptors.py +382 -0
- package/tools/gen-hosting-codes.mjs +1 -1
- package/tools/gen-python-declarations.mjs +13 -1
- package/tools/mod-host-admit.mjs +74 -0
- package/tools/scan.py +260 -4
|
@@ -0,0 +1,830 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
// Copyright (C) 2026 Pau Aliagas <linuxnow@gmail.com>
|
|
3
|
+
/**
|
|
4
|
+
* `plugin-qualify-sync`: every plugin on the machine qualified once, and again only when its files change, so
|
|
5
|
+
* plugin-hostd can run with `require_pins 1` and chain what it was given.
|
|
6
|
+
*
|
|
7
|
+
* A run finds every plugin binary on the search paths (each `.clap` under the CLAP directories, each plugin of each
|
|
8
|
+
* `.lv2` bundle under the LV2 ones), and takes each one's file set as plugin-hostd collects it for a pin: the `.clap`
|
|
9
|
+
* alone, or the bundle's `manifest.ttl` and every file the manifest names for the plugin. A plugin whose files hash
|
|
10
|
+
* as recorded is skipped. Every other one gets the quick qualification: the probe runner's pin, RT-safety sweep, cost
|
|
11
|
+
* and latency, then its descriptor; no soak, which its record says. Each plugin runs in processes of its own under a
|
|
12
|
+
* wall clock, so a crash, a hang or a refusal is recorded and the run goes on to the next one.
|
|
13
|
+
*
|
|
14
|
+
* What a run leaves: the state file (what was qualified, from which bytes, and how it ended), the qualification
|
|
15
|
+
* records and the descriptors in their directories, and the pin file plugin-hostd loads as `pin_file`, written whole.
|
|
16
|
+
* When a daemon answers on the command port, the pins that changed are also sent to it with `pin_set`.
|
|
17
|
+
*/
|
|
18
|
+
import { spawnSync } from "node:child_process";
|
|
19
|
+
import { createHash } from "node:crypto";
|
|
20
|
+
import { closeSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync, writeSync } from "node:fs";
|
|
21
|
+
import { createConnection } from "node:net";
|
|
22
|
+
import { homedir, tmpdir } from "node:os";
|
|
23
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
24
|
+
|
|
25
|
+
import { clapQualifyTools } from "./clap-qualify.js";
|
|
26
|
+
import { clapSearchPath, parseClapScan } from "./clap-loader.js";
|
|
27
|
+
import { HOSTING_CODE } from "./hosting-codes.js";
|
|
28
|
+
import { shippedHostProfile, type HostProfile } from "./host-profiles.js";
|
|
29
|
+
import { lv2BundlesOnPath, manifestPlugins } from "./lv2-loader.js";
|
|
30
|
+
import { judgmentPinEntries, pinsFile, type PinEntry } from "./pins-file.js";
|
|
31
|
+
import type { Judgment } from "./probe-judge.js";
|
|
32
|
+
import { qualifyTargets } from "./probe-runner.js";
|
|
33
|
+
import { writeQualification, type NotMeasured } from "./qualification-record.js";
|
|
34
|
+
import { DESCRIPTOR_DIR_STATE_SUBDIR, DESCRIPTOR_DIR_SYSTEM } from "./qualify-declarations.js";
|
|
35
|
+
import { toolsDir } from "./tool-paths.js";
|
|
36
|
+
import { writeFileWhole } from "./whole-file.js";
|
|
37
|
+
|
|
38
|
+
// --- the settings -----------------------------------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
/** The settings file a system service reads, `key value` per line. */
|
|
41
|
+
export const SYNC_CONF = "/etc/omx/qualify.conf";
|
|
42
|
+
|
|
43
|
+
/** The LV2 directories plugin-hostd searches when `LV2_PATH` is unset (its `lv2_path` default). */
|
|
44
|
+
export const HOSTD_LV2_PATH = ["/usr/lib64/lv2", "/usr/lib/lv2", "/usr/local/lib/lv2"];
|
|
45
|
+
|
|
46
|
+
/** Zynthian's own LV2 directories: the image's plugins and the ones its user installs. */
|
|
47
|
+
export const ZYNTHIAN_LV2_DIRS = ["/zynthian/zynthian-plugins/lv2", "/zynthian/zynthian-my-data/plugins/lv2"];
|
|
48
|
+
|
|
49
|
+
export interface SyncSetting {
|
|
50
|
+
readonly key: string;
|
|
51
|
+
readonly meaning: string;
|
|
52
|
+
/** The default as `--help` and the README print it. */
|
|
53
|
+
readonly default: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Every setting, declared once: the settings file, the flags (`--pin-file` for `pin_file`) and `--help` read this. */
|
|
57
|
+
export const SYNC_SETTINGS: readonly SyncSetting[] = [
|
|
58
|
+
{ key: "lv2_path", meaning: "the LV2 directories, colon-separated, in the order plugin-hostd searches them", default: "$LV2_PATH, else " + HOSTD_LV2_PATH.join(":") },
|
|
59
|
+
{ key: "lv2_extra", meaning: "more LV2 directories, searched after lv2_path", default: ZYNTHIAN_LV2_DIRS.join(":") },
|
|
60
|
+
{ key: "clap_path", meaning: "the CLAP directories, colon-separated", default: "$CLAP_PATH, then ~/.clap, then /usr/lib/clap" },
|
|
61
|
+
{ key: "clap_extra", meaning: "more CLAP directories, searched after clap_path", default: "" },
|
|
62
|
+
{ 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" },
|
|
63
|
+
{ key: "qualification_dir", meaning: "where the qualification records go", default: "/var/lib/omx/qualification (system), else $XDG_STATE_HOME/omx/qualification" },
|
|
64
|
+
{ key: "descriptor_dir", meaning: "where the scanned descriptors go", default: `${DESCRIPTOR_DIR_SYSTEM} (system), else $XDG_STATE_HOME/${DESCRIPTOR_DIR_STATE_SUBDIR}` },
|
|
65
|
+
{ key: "state_file", meaning: "what was qualified, from which bytes, and how it ended", default: "/var/lib/omx/qualify-sync.json (system), else $XDG_STATE_HOME/omx/qualify-sync.json" },
|
|
66
|
+
{ key: "budget_s", meaning: "the wall clock one plugin gets, in seconds; past it the plugin is recorded as hung", default: "60" },
|
|
67
|
+
{ key: "host_profile", meaning: "the host the verdict is for (a shipped profile id)", default: "omx-clap-host" },
|
|
68
|
+
{ key: "hostd_port", meaning: "plugin-hostd's command port the new pins are sent to; 0 sends none", default: "5555" },
|
|
69
|
+
{ key: "hostd_feedback_port", meaning: "plugin-hostd's feedback port, connected before the command port; 0 is the command port's next", default: "0" },
|
|
70
|
+
{ key: "settle_s", meaning: "wait until the plugin directories have been quiet this long before a run", default: "0" },
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
export interface SyncSettings {
|
|
74
|
+
readonly lv2Dirs: readonly string[];
|
|
75
|
+
readonly clapDirs: readonly string[];
|
|
76
|
+
readonly pinFile: string;
|
|
77
|
+
readonly qualificationDir: string;
|
|
78
|
+
readonly descriptorDir: string;
|
|
79
|
+
readonly stateFile: string;
|
|
80
|
+
readonly budgetS: number;
|
|
81
|
+
readonly hostProfile: HostProfile;
|
|
82
|
+
readonly hostdPort: number;
|
|
83
|
+
readonly hostdFeedbackPort: number;
|
|
84
|
+
readonly settleS: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A settings file read: one `key value` per line, `#` starts a comment; a key alone is the empty value. An unknown key is refused by line. */
|
|
88
|
+
export function parseSyncConf(text: string, source = SYNC_CONF): { ok: true; values: Map<string, string> } | { ok: false; error: string } {
|
|
89
|
+
const known = new Set(SYNC_SETTINGS.map((s) => s.key));
|
|
90
|
+
const values = new Map<string, string>();
|
|
91
|
+
const lines = text.split("\n");
|
|
92
|
+
for (let n = 0; n < lines.length; n++) {
|
|
93
|
+
const line = lines[n]!.replace(/#.*$/, "").trim();
|
|
94
|
+
if (line === "") continue;
|
|
95
|
+
const m = /^(\S+)(?:\s+(.*))?$/.exec(line)!;
|
|
96
|
+
const key = m[1]!;
|
|
97
|
+
if (!known.has(key)) return { ok: false, error: `${source}:${String(n + 1)}: ${key} is not a setting` };
|
|
98
|
+
values.set(key, (m[2] ?? "").trim());
|
|
99
|
+
}
|
|
100
|
+
return { ok: true, values };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface SyncArgs {
|
|
104
|
+
readonly config?: string;
|
|
105
|
+
readonly system: boolean;
|
|
106
|
+
readonly force: boolean;
|
|
107
|
+
readonly help: boolean;
|
|
108
|
+
/** The settings given as flags, by key. */
|
|
109
|
+
readonly values: ReadonlyMap<string, string>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export const flagOf = (key: string): string => `--${key.replace(/_/g, "-")}`;
|
|
113
|
+
|
|
114
|
+
export function parseSyncArgs(argv: readonly string[]): { ok: true; args: SyncArgs } | { ok: false; error: string } {
|
|
115
|
+
const byFlag = new Map(SYNC_SETTINGS.map((s) => [flagOf(s.key), s.key]));
|
|
116
|
+
const values = new Map<string, string>();
|
|
117
|
+
let config: string | undefined;
|
|
118
|
+
let system = false;
|
|
119
|
+
let force = false;
|
|
120
|
+
let help = false;
|
|
121
|
+
for (let i = 0; i < argv.length; i++) {
|
|
122
|
+
const a = argv[i]!;
|
|
123
|
+
if (a === "--system") system = true;
|
|
124
|
+
else if (a === "--force") force = true;
|
|
125
|
+
else if (a === "--help") help = true;
|
|
126
|
+
else if (a === "--config" || byFlag.has(a)) {
|
|
127
|
+
const v = argv[++i];
|
|
128
|
+
if (v === undefined) return { ok: false, error: `${a} needs a value` };
|
|
129
|
+
if (a === "--config") config = v;
|
|
130
|
+
else values.set(byFlag.get(a)!, v);
|
|
131
|
+
} else return { ok: false, error: `unknown argument ${a}` };
|
|
132
|
+
}
|
|
133
|
+
return { ok: true, args: { ...(config === undefined ? {} : { config }), system, force, help, values } };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function syncHelpText(): string {
|
|
137
|
+
const rows = SYNC_SETTINGS.map((s) => ` ${`${flagOf(s.key)} <value>`.padEnd(28)}${s.meaning}${s.default === "" ? "" : ` (default: ${s.default})`}`);
|
|
138
|
+
return [
|
|
139
|
+
"plugin-qualify-sync: qualify every plugin on the search paths once, and again when its files change",
|
|
140
|
+
"",
|
|
141
|
+
` --config <file> the settings file (default: ${SYNC_CONF}; a missing file is the defaults)`,
|
|
142
|
+
" --system a system service's directories, under /var/lib/omx",
|
|
143
|
+
" --force qualify every plugin again, recorded or not",
|
|
144
|
+
" --help this text",
|
|
145
|
+
...rows,
|
|
146
|
+
"",
|
|
147
|
+
"every setting is also a line of the settings file, its key as the first word",
|
|
148
|
+
"exit 0 the run went through (whatever the verdicts), 2 it could not run",
|
|
149
|
+
"",
|
|
150
|
+
].join("\n");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const dirList = (text: string): string[] => text.split(":").filter((d) => d !== "");
|
|
154
|
+
|
|
155
|
+
/** The settings of a run: a flag over the settings file over the default. */
|
|
156
|
+
export function resolveSyncSettings(
|
|
157
|
+
file: ReadonlyMap<string, string>,
|
|
158
|
+
args: SyncArgs,
|
|
159
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
160
|
+
home: string = homedir(),
|
|
161
|
+
): { ok: true; settings: SyncSettings } | { ok: false; error: string } {
|
|
162
|
+
const get = (key: string): string | undefined => args.values.get(key) ?? file.get(key);
|
|
163
|
+
const state = env["XDG_STATE_HOME"] !== undefined && env["XDG_STATE_HOME"] !== "" ? env["XDG_STATE_HOME"] : join(home, ".local", "state");
|
|
164
|
+
const place = (key: string, system: string, user: string): string => {
|
|
165
|
+
const v = get(key);
|
|
166
|
+
return v !== undefined && v !== "" ? v : args.system ? system : join(state, user);
|
|
167
|
+
};
|
|
168
|
+
const lv2Named = get("lv2_path");
|
|
169
|
+
const lv2Base = lv2Named !== undefined && lv2Named !== "" ? dirList(lv2Named) : dirList(env["LV2_PATH"] ?? "").length > 0 ? dirList(env["LV2_PATH"]!) : HOSTD_LV2_PATH;
|
|
170
|
+
const clapNamed = get("clap_path");
|
|
171
|
+
const clapBase = clapNamed !== undefined && clapNamed !== "" ? dirList(clapNamed) : clapSearchPath(env, home);
|
|
172
|
+
const number = (key: string, min: number): number | undefined => {
|
|
173
|
+
const v = Number(get(key) ?? SYNC_SETTINGS.find((s) => s.key === key)!.default);
|
|
174
|
+
return Number.isFinite(v) && v >= min ? v : undefined;
|
|
175
|
+
};
|
|
176
|
+
const budgetS = number("budget_s", 1);
|
|
177
|
+
const hostdPort = number("hostd_port", 0);
|
|
178
|
+
const feedbackPort = number("hostd_feedback_port", 0);
|
|
179
|
+
const settleS = number("settle_s", 0);
|
|
180
|
+
if (budgetS === undefined) return { ok: false, error: "budget_s must be a number of seconds, 1 or more" };
|
|
181
|
+
if (hostdPort === undefined || !Number.isInteger(hostdPort) || hostdPort > 65535) return { ok: false, error: "hostd_port must be a port number, or 0" };
|
|
182
|
+
if (feedbackPort === undefined || !Number.isInteger(feedbackPort) || feedbackPort > 65535) return { ok: false, error: "hostd_feedback_port must be a port number, or 0" };
|
|
183
|
+
if (settleS === undefined) return { ok: false, error: "settle_s must be a number of seconds" };
|
|
184
|
+
const profileId = get("host_profile") || "omx-clap-host";
|
|
185
|
+
const hostProfile = shippedHostProfile(profileId);
|
|
186
|
+
if (hostProfile === undefined) return { ok: false, error: `host_profile ${profileId} is not a shipped host profile` };
|
|
187
|
+
const once = (dirs: readonly string[]): string[] => [...new Set(dirs.map((d) => resolve(d)))];
|
|
188
|
+
return {
|
|
189
|
+
ok: true,
|
|
190
|
+
settings: {
|
|
191
|
+
lv2Dirs: once([...lv2Base, ...dirList(get("lv2_extra") ?? ZYNTHIAN_LV2_DIRS.join(":"))]),
|
|
192
|
+
clapDirs: once([...clapBase, ...dirList(get("clap_extra") ?? "")]),
|
|
193
|
+
pinFile: place("pin_file", "/var/lib/omx/qualified.pins", join("omx", "qualified.pins")),
|
|
194
|
+
qualificationDir: place("qualification_dir", "/var/lib/omx/qualification", join("omx", "qualification")),
|
|
195
|
+
descriptorDir: place("descriptor_dir", DESCRIPTOR_DIR_SYSTEM, DESCRIPTOR_DIR_STATE_SUBDIR),
|
|
196
|
+
stateFile: place("state_file", "/var/lib/omx/qualify-sync.json", join("omx", "qualify-sync.json")),
|
|
197
|
+
budgetS,
|
|
198
|
+
hostProfile,
|
|
199
|
+
hostdPort,
|
|
200
|
+
hostdFeedbackPort: feedbackPort === 0 && hostdPort !== 0 ? hostdPort + 1 : feedbackPort,
|
|
201
|
+
settleS,
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// --- what is on the machine -------------------------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
/** One thing that is qualified as a unit: a `.clap` file (every plugin in it), or one plugin of an LV2 bundle. */
|
|
209
|
+
export interface SyncItem {
|
|
210
|
+
/** `clap:<path of the .clap>`, or `lv2:<uri>`. */
|
|
211
|
+
readonly key: string;
|
|
212
|
+
readonly format: "clap" | "lv2";
|
|
213
|
+
/** The directory the files are relative to: the `.clap`'s, or the bundle. */
|
|
214
|
+
readonly root: string;
|
|
215
|
+
/** The files plugin-hostd's pin holds, relative to `root`. */
|
|
216
|
+
readonly files: readonly string[];
|
|
217
|
+
/** The LV2 plugin's uri. */
|
|
218
|
+
readonly uri?: string;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const CLAP_SUFFIX = ".clap";
|
|
222
|
+
|
|
223
|
+
/** Every `.clap` under a directory, its subdirectories included, in name order; a directory reached twice is walked once. */
|
|
224
|
+
function clapFiles(dir: string, seen: Set<string>, problems: string[]): string[] {
|
|
225
|
+
let real: string;
|
|
226
|
+
try {
|
|
227
|
+
real = resolve(dir);
|
|
228
|
+
const st = statSync(real);
|
|
229
|
+
if (!st.isDirectory() || seen.has(`${st.dev}:${st.ino}`)) return [];
|
|
230
|
+
seen.add(`${st.dev}:${st.ino}`);
|
|
231
|
+
} catch (e) {
|
|
232
|
+
if ((e as NodeJS.ErrnoException).code !== "ENOENT") problems.push(`cannot read ${dir}: ${(e as NodeJS.ErrnoException).code ?? String(e)}`);
|
|
233
|
+
return [];
|
|
234
|
+
}
|
|
235
|
+
let names: string[];
|
|
236
|
+
try {
|
|
237
|
+
names = readdirSync(real).sort();
|
|
238
|
+
} catch (e) {
|
|
239
|
+
problems.push(`cannot read ${dir}: ${(e as NodeJS.ErrnoException).code ?? String(e)}`);
|
|
240
|
+
return [];
|
|
241
|
+
}
|
|
242
|
+
const out: string[] = [];
|
|
243
|
+
for (const name of names) {
|
|
244
|
+
const path = join(dir, name);
|
|
245
|
+
let st;
|
|
246
|
+
try {
|
|
247
|
+
st = statSync(path);
|
|
248
|
+
} catch {
|
|
249
|
+
continue; // a dangling link
|
|
250
|
+
}
|
|
251
|
+
if (st.isDirectory()) out.push(...clapFiles(path, seen, problems));
|
|
252
|
+
else if (st.isFile() && name.endsWith(CLAP_SUFFIX)) out.push(path);
|
|
253
|
+
}
|
|
254
|
+
return out;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Every item on the search paths. A `.clap` is one item; an LV2 plugin is one item, found in the first bundle on the
|
|
259
|
+
* LV2 directories that declares it, as plugin-hostd finds it. A directory that cannot be read is a problem named, and
|
|
260
|
+
* the others are still walked.
|
|
261
|
+
*/
|
|
262
|
+
export function discoverSyncItems(settings: Pick<SyncSettings, "lv2Dirs" | "clapDirs">): { items: SyncItem[]; problems: string[] } {
|
|
263
|
+
const items: SyncItem[] = [];
|
|
264
|
+
const problems: string[] = [];
|
|
265
|
+
const seen = new Set<string>();
|
|
266
|
+
const clap = new Set<string>();
|
|
267
|
+
for (const dir of settings.clapDirs) {
|
|
268
|
+
for (const file of clapFiles(dir, seen, problems)) {
|
|
269
|
+
const path = resolve(file);
|
|
270
|
+
if (clap.has(path)) continue;
|
|
271
|
+
clap.add(path);
|
|
272
|
+
items.push({ key: `clap:${path}`, format: "clap", root: dirname(path), files: [basename(path)] });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const lv2 = new Set<string>();
|
|
276
|
+
for (const dir of settings.lv2Dirs) {
|
|
277
|
+
let bundles: string[];
|
|
278
|
+
try {
|
|
279
|
+
bundles = lv2BundlesOnPath({ LV2_PATH: dir });
|
|
280
|
+
} catch (e) {
|
|
281
|
+
problems.push(`cannot read ${dir}: ${(e as NodeJS.ErrnoException).code ?? String(e)}`);
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
for (const bundle of bundles) {
|
|
285
|
+
let ttl: string;
|
|
286
|
+
try {
|
|
287
|
+
ttl = readFileSync(join(bundle, "manifest.ttl"), "utf8");
|
|
288
|
+
} catch (e) {
|
|
289
|
+
problems.push(`cannot read ${join(bundle, "manifest.ttl")}: ${(e as NodeJS.ErrnoException).code ?? String(e)}`);
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
for (const p of manifestPlugins(ttl)) {
|
|
293
|
+
if (lv2.has(p.uri)) continue;
|
|
294
|
+
lv2.add(p.uri);
|
|
295
|
+
items.push({ key: `lv2:${p.uri}`, format: "lv2", root: bundle, files: ["manifest.ttl", ...p.files.filter((f) => f !== "manifest.ttl")], uri: p.uri });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return { items, problems };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// --- the file set -----------------------------------------------------------------------------------------------------
|
|
303
|
+
|
|
304
|
+
/** One file of a set: its SHA-256, as plugin-hostd's `pin.h` digests it, and the stat it was taken at. */
|
|
305
|
+
export interface FileDigest {
|
|
306
|
+
readonly path: string;
|
|
307
|
+
readonly sha256: string;
|
|
308
|
+
readonly size: number;
|
|
309
|
+
readonly mtimeMs: number;
|
|
310
|
+
readonly ino: number;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** The SHA-256 of a file's bytes, read in pieces over one descriptor. */
|
|
314
|
+
export function fileSha256(path: string): string {
|
|
315
|
+
const hash = createHash("sha256");
|
|
316
|
+
const fd = openSync(path, "r");
|
|
317
|
+
try {
|
|
318
|
+
const piece = Buffer.alloc(1 << 20);
|
|
319
|
+
for (let got = readSync(fd, piece); got > 0; got = readSync(fd, piece)) hash.update(piece.subarray(0, got));
|
|
320
|
+
} finally {
|
|
321
|
+
closeSync(fd);
|
|
322
|
+
}
|
|
323
|
+
return hash.digest("hex");
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* The digests of the files under `root`. A file whose size, modification time and inode are the ones `known` holds
|
|
328
|
+
* keeps that digest without being read again, so a run over an unchanged machine reads no binary; any other is hashed.
|
|
329
|
+
* `undefined` when a file is gone or cannot be read.
|
|
330
|
+
*/
|
|
331
|
+
export function digestFiles(root: string, files: readonly string[], known: readonly FileDigest[] = []): FileDigest[] | undefined {
|
|
332
|
+
const out: FileDigest[] = [];
|
|
333
|
+
for (const path of [...new Set(files)].sort()) {
|
|
334
|
+
const full = join(root, path);
|
|
335
|
+
try {
|
|
336
|
+
const st = statSync(full);
|
|
337
|
+
if (!st.isFile()) return undefined;
|
|
338
|
+
const prior = known.find((k) => k.path === path);
|
|
339
|
+
const same = prior !== undefined && prior.size === st.size && prior.mtimeMs === st.mtimeMs && prior.ino === st.ino;
|
|
340
|
+
out.push({ path, sha256: same ? prior.sha256 : fileSha256(full), size: st.size, mtimeMs: st.mtimeMs, ino: st.ino });
|
|
341
|
+
} catch {
|
|
342
|
+
return undefined;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return out;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// --- the state --------------------------------------------------------------------------------------------------------
|
|
349
|
+
|
|
350
|
+
export const SYNC_STATE_SCHEMA = "plugin-qualify.sync-state/1";
|
|
351
|
+
|
|
352
|
+
/** How the quick qualification of one plugin ended. */
|
|
353
|
+
export const SYNC_OUTCOMES = ["qualified", "refused", "crashed", "hung", "unmeasured"] as const;
|
|
354
|
+
export type SyncOutcome = (typeof SYNC_OUTCOMES)[number];
|
|
355
|
+
|
|
356
|
+
/** The codes of a plugin the sync could not measure, beside the probe runner's own. */
|
|
357
|
+
export const SYNC_CODE = {
|
|
358
|
+
/** The scanner died loading the `.clap`: no plugin of it could be listed. */
|
|
359
|
+
scanCrashed: "qualify.sync.scan-crashed",
|
|
360
|
+
/** The scanner did not finish listing the `.clap` in its time. */
|
|
361
|
+
scanHung: "qualify.sync.scan-hung",
|
|
362
|
+
/** The scanner listed the plugin but could not create or init it. */
|
|
363
|
+
scanRefused: "qualify.sync.scan-refused",
|
|
364
|
+
/** The probe runner wrote no document to judge. */
|
|
365
|
+
notMeasured: "qualify.sync.not-measured",
|
|
366
|
+
} as const;
|
|
367
|
+
|
|
368
|
+
/** What the quick qualification never measures, stated in every record it writes. */
|
|
369
|
+
export const QUICK_NOT_MEASURED: readonly NotMeasured[] = [{ dimension: "stability", code: HOSTING_CODE.stabilityUnsoaked }];
|
|
370
|
+
|
|
371
|
+
export interface SyncPlugin {
|
|
372
|
+
/** plugin-hostd's uri of the plugin (`clap:<path>#<id>` or the LV2 uri), or `clap:<path>` when no plugin could be listed. */
|
|
373
|
+
readonly uri: string;
|
|
374
|
+
readonly outcome: SyncOutcome;
|
|
375
|
+
/** The code that decided a plugin that is not qualified. */
|
|
376
|
+
readonly code?: string;
|
|
377
|
+
readonly detail?: string;
|
|
378
|
+
/** Its pin, when the run took a whole one. */
|
|
379
|
+
readonly pin?: PinEntry;
|
|
380
|
+
/** The record written for it. */
|
|
381
|
+
readonly record?: string;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export interface SyncEntry {
|
|
385
|
+
readonly format: "clap" | "lv2";
|
|
386
|
+
readonly root: string;
|
|
387
|
+
/** Every file the item's pin and its manifest hold, as they were when it was qualified. */
|
|
388
|
+
readonly files: readonly FileDigest[];
|
|
389
|
+
readonly plugins: readonly SyncPlugin[];
|
|
390
|
+
readonly qualifiedAt: string;
|
|
391
|
+
readonly seconds: number;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export interface SyncState {
|
|
395
|
+
readonly schema: typeof SYNC_STATE_SCHEMA;
|
|
396
|
+
readonly entries: Readonly<Record<string, SyncEntry>>;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const EMPTY_STATE: SyncState = { schema: SYNC_STATE_SCHEMA, entries: {} };
|
|
400
|
+
|
|
401
|
+
/** The state file, or an empty state when there is none. One that does not read is moved aside and the run starts over. */
|
|
402
|
+
export function readSyncState(path: string, log: (line: string) => void = () => {}): SyncState {
|
|
403
|
+
let text: string;
|
|
404
|
+
try {
|
|
405
|
+
text = readFileSync(path, "utf8");
|
|
406
|
+
} catch (e) {
|
|
407
|
+
if ((e as NodeJS.ErrnoException).code === "ENOENT") return EMPTY_STATE;
|
|
408
|
+
throw e;
|
|
409
|
+
}
|
|
410
|
+
try {
|
|
411
|
+
const state = JSON.parse(text) as SyncState;
|
|
412
|
+
if (state.schema === SYNC_STATE_SCHEMA && typeof state.entries === "object" && state.entries !== null) return state;
|
|
413
|
+
} catch {
|
|
414
|
+
// read as corrupt below
|
|
415
|
+
}
|
|
416
|
+
renameSync(path, `${path}.unreadable`);
|
|
417
|
+
log(`${path} does not read as a sync state: moved to ${path}.unreadable, and every plugin is qualified again`);
|
|
418
|
+
return EMPTY_STATE;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Whether an item is recorded from the bytes it has now: every file of its set, and every file of its recorded pin, unchanged. */
|
|
422
|
+
export function recordedAsIs(item: SyncItem, entry: SyncEntry | undefined): { same: boolean; files?: FileDigest[] } {
|
|
423
|
+
if (entry === undefined || entry.root !== item.root) return { same: false };
|
|
424
|
+
const names = [...new Set([...item.files, ...entry.files.map((f) => f.path)])];
|
|
425
|
+
const now = digestFiles(item.root, names, entry.files);
|
|
426
|
+
if (now === undefined) return { same: false };
|
|
427
|
+
const same = now.length === entry.files.length && now.every((f) => entry.files.some((e) => e.path === f.path && e.sha256 === f.sha256));
|
|
428
|
+
return { same, files: now };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// --- one item ---------------------------------------------------------------------------------------------------------
|
|
432
|
+
|
|
433
|
+
export interface SyncContext {
|
|
434
|
+
readonly settings: SyncSettings;
|
|
435
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
436
|
+
readonly log: (line: string) => void;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** A judged run's outcome for the host: qualified, refused, or why it was not measured. */
|
|
440
|
+
function outcomeOf(judgment: Judgment, host: string): { outcome: SyncOutcome; code?: string } {
|
|
441
|
+
const v = judgment.hosts.find((h) => h.verdict.host === host)?.verdict;
|
|
442
|
+
if (v === undefined) return { outcome: "unmeasured", code: SYNC_CODE.notMeasured };
|
|
443
|
+
const code = v.deciding;
|
|
444
|
+
if (!v.measured) return { outcome: code === "probe.crashed" ? "crashed" : code === "probe.timeout" ? "hung" : "unmeasured", ...(code === undefined ? {} : { code }) };
|
|
445
|
+
if (v.rating === "suitable" || v.rating === "conditional") return { outcome: "qualified", ...(code === undefined ? {} : { code }) };
|
|
446
|
+
return { outcome: v.rating === "unsuitable" ? "refused" : "unmeasured", ...(code === undefined ? {} : { code }) };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const safe = (text: string): string => text.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
450
|
+
|
|
451
|
+
/** The record of a plugin no probe output exists for: what it is, the bytes it was, and why nothing was measured. */
|
|
452
|
+
function writeUnmeasuredRecord(ctx: SyncContext, item: SyncItem, files: readonly FileDigest[], plugin: Omit<SyncPlugin, "record">): string {
|
|
453
|
+
const main = files.find((f) => f.path === item.files[item.format === "clap" ? 0 : 1]) ?? files[0];
|
|
454
|
+
const path = join(ctx.settings.qualificationDir, `${safe(basename(plugin.uri))}-${(main?.sha256 ?? "").slice(0, 12)}.unmeasured.json`);
|
|
455
|
+
const record = {
|
|
456
|
+
schema: "plugin-qualify.unmeasured-record/1",
|
|
457
|
+
plugin: { format: item.format, uri: plugin.uri },
|
|
458
|
+
binary: { root: item.root, files: files.map((f) => ({ path: f.path, sha256: f.sha256 })) },
|
|
459
|
+
outcome: plugin.outcome,
|
|
460
|
+
code: plugin.code,
|
|
461
|
+
...(plugin.detail === undefined ? {} : { detail: plugin.detail }),
|
|
462
|
+
notMeasured: [{ dimension: "all", code: plugin.code ?? SYNC_CODE.notMeasured }, ...QUICK_NOT_MEASURED],
|
|
463
|
+
measuredAt: new Date().toISOString(),
|
|
464
|
+
};
|
|
465
|
+
writeFileWhole(path, `${JSON.stringify(record, null, 2)}\n`);
|
|
466
|
+
return path;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** Each judged run of a pass as the plugins it qualified: the record written, the pin taken, the outcome. */
|
|
470
|
+
function judgedPlugins(ctx: SyncContext, runs: readonly { bytes: Buffer; judgment: Judgment }[]): SyncPlugin[] {
|
|
471
|
+
const host = ctx.settings.hostProfile.id;
|
|
472
|
+
const entries = runs.map(({ bytes, judgment }) => {
|
|
473
|
+
const { recordPath } = writeQualification(ctx.settings.qualificationDir, bytes, judgment, QUICK_NOT_MEASURED);
|
|
474
|
+
const pin = judgmentPinEntries(judgment, host)[0];
|
|
475
|
+
const { outcome, code } = outcomeOf(judgment, host);
|
|
476
|
+
const plugin: SyncPlugin = { uri: pin?.uri ?? judgment.output.plugin.id, outcome, ...(code === undefined ? {} : { code }), ...(pin === undefined ? {} : { pin }), record: recordPath };
|
|
477
|
+
return { plugin, judgedHere: judgment.hosts.some((h) => h.verdict.host === host) };
|
|
478
|
+
});
|
|
479
|
+
// The pass also judges the console's other tier, which writes its own record; that run is no second plugin of this
|
|
480
|
+
// sweep, so only the runs that judged this host count, and a plugin none of them judged is counted once.
|
|
481
|
+
const here = entries.filter((e) => e.judgedHere).map((e) => e.plugin);
|
|
482
|
+
return here.length > 0 || entries.length === 0 ? here : [entries[0]!.plugin];
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function runPass(ctx: SyncContext, target: Parameters<typeof qualifyTargets>[0][number], env: Readonly<Record<string, string | undefined>>) {
|
|
486
|
+
return qualifyTargets([target], { rates: [ctx.settings.hostProfile.cost.rate], profiles: [ctx.settings.hostProfile], wallS: ctx.settings.budgetS, env });
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** The descriptors of a scanned `.clap`, through the one descriptor writer; a failure is named and the run goes on. */
|
|
490
|
+
function writeClapDescriptors(ctx: SyncContext, scan: string): void {
|
|
491
|
+
const r = spawnSync("python3", [join(toolsDir(), "descriptors.py"), "clap", "--descriptor-dir", ctx.settings.descriptorDir], {
|
|
492
|
+
input: scan,
|
|
493
|
+
encoding: "utf8",
|
|
494
|
+
timeout: ctx.settings.budgetS * 1000,
|
|
495
|
+
env: { ...ctx.env },
|
|
496
|
+
});
|
|
497
|
+
if (r.status !== 0) ctx.log(`descriptors of a .clap not all written (${r.error?.message ?? `exit ${String(r.status)}`}): ${(r.stderr ?? "").trim()}`);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function qualifyClap(ctx: SyncContext, item: SyncItem): SyncPlugin[] {
|
|
501
|
+
const file = join(item.root, item.files[0]!);
|
|
502
|
+
const fileUri = `clap:${file}`;
|
|
503
|
+
let tool: string;
|
|
504
|
+
try {
|
|
505
|
+
tool = clapQualifyTools(ctx.env, existsSync).tool;
|
|
506
|
+
} catch (e) {
|
|
507
|
+
return [{ uri: fileUri, outcome: "unmeasured", code: SYNC_CODE.notMeasured, detail: (e as Error).message }];
|
|
508
|
+
}
|
|
509
|
+
// the scanner lists the plugins of the file in a child of its own, and says how that child ended
|
|
510
|
+
const r = spawnSync(tool, ["--json", file], { encoding: "utf8", timeout: Math.max(ctx.settings.budgetS, 35) * 1000, killSignal: "SIGKILL", maxBuffer: 64 << 20 });
|
|
511
|
+
let scanned;
|
|
512
|
+
try {
|
|
513
|
+
if (r.error !== undefined) throw r.error;
|
|
514
|
+
scanned = parseClapScan(r.stdout).files.find((f) => resolve(f.path) === file);
|
|
515
|
+
if (scanned === undefined) throw new Error("the scan holds no entry for the file");
|
|
516
|
+
} catch (e) {
|
|
517
|
+
const hung = (r.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT";
|
|
518
|
+
return [{ uri: fileUri, outcome: hung ? "hung" : "unmeasured", code: hung ? SYNC_CODE.scanHung : SYNC_CODE.notMeasured, detail: (e as Error).message }];
|
|
519
|
+
}
|
|
520
|
+
if ("error" in scanned) {
|
|
521
|
+
const hung = /timed out/.test(scanned.error);
|
|
522
|
+
return [{ uri: fileUri, outcome: hung ? "hung" : "crashed", code: hung ? SYNC_CODE.scanHung : SYNC_CODE.scanCrashed, detail: scanned.error }];
|
|
523
|
+
}
|
|
524
|
+
writeClapDescriptors(ctx, r.stdout);
|
|
525
|
+
const out: SyncPlugin[] = [];
|
|
526
|
+
for (const p of scanned.plugins) {
|
|
527
|
+
const uri = `${fileUri}#${p.id}`;
|
|
528
|
+
if ("error" in p) {
|
|
529
|
+
out.push({ uri, outcome: "unmeasured", code: SYNC_CODE.scanRefused, detail: p.error });
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
const pass = runPass(ctx, { format: "clap", uri: `urn:clap:${p.id}`, bundle: item.root, binary: item.files[0]!, binaryPath: file }, ctx.env);
|
|
533
|
+
for (const f of pass.failures) out.push({ uri, outcome: "unmeasured", code: SYNC_CODE.notMeasured, detail: f.problem });
|
|
534
|
+
out.push(...judgedPlugins(ctx, pass.runs));
|
|
535
|
+
}
|
|
536
|
+
return out;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** An LV2 plugin, measured in a world of its own bundle only, as plugin-hostd runs a pinned LV2 plugin. */
|
|
540
|
+
function qualifyLv2(ctx: SyncContext, item: SyncItem): SyncPlugin[] {
|
|
541
|
+
const world = mkdtempSync(join(tmpdir(), "plugin-qualify-sync-world-"));
|
|
542
|
+
try {
|
|
543
|
+
symlinkSync(item.root, join(world, basename(item.root)));
|
|
544
|
+
const pass = runPass(ctx, { format: "lv2", uri: item.uri!, bundle: item.root }, { ...ctx.env, LV2_PATH: world });
|
|
545
|
+
return [...pass.failures.map((f): SyncPlugin => ({ uri: item.uri!, outcome: "unmeasured", code: SYNC_CODE.notMeasured, detail: f.problem })), ...judgedPlugins(ctx, pass.runs)];
|
|
546
|
+
} finally {
|
|
547
|
+
rmSync(world, { recursive: true, force: true });
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** One item qualified, whatever happens to it: an error of the sync's own while measuring it is its outcome, not the run's end. */
|
|
552
|
+
export function qualifyItem(ctx: SyncContext, item: SyncItem, files: readonly FileDigest[]): SyncEntry {
|
|
553
|
+
const started = Date.now();
|
|
554
|
+
let plugins: SyncPlugin[];
|
|
555
|
+
try {
|
|
556
|
+
plugins = item.format === "clap" ? qualifyClap(ctx, item) : qualifyLv2(ctx, item);
|
|
557
|
+
} catch (e) {
|
|
558
|
+
plugins = [{ uri: item.uri ?? item.key, outcome: "unmeasured", code: SYNC_CODE.notMeasured, detail: (e as Error).message }];
|
|
559
|
+
}
|
|
560
|
+
if (plugins.length === 0) plugins = [{ uri: item.uri ?? item.key, outcome: "unmeasured", code: SYNC_CODE.notMeasured, detail: "no plugin was listed" }];
|
|
561
|
+
plugins = plugins.map((p) => (p.record !== undefined ? p : { ...p, record: writeUnmeasuredRecord(ctx, item, files, p) }));
|
|
562
|
+
// the pin's own files join the set, so a file the manifest reader missed still makes a change seen
|
|
563
|
+
const pinned = plugins.flatMap((p) => p.pin?.integrity.files?.map((f) => f.path) ?? []);
|
|
564
|
+
const all = digestFiles(item.root, [...item.files, ...pinned], files) ?? [...files];
|
|
565
|
+
return { format: item.format, root: item.root, files: all, plugins, qualifiedAt: new Date(started).toISOString(), seconds: (Date.now() - started) / 1000 };
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// --- the outputs ------------------------------------------------------------------------------------------------------
|
|
569
|
+
|
|
570
|
+
/** The pin file's text: every whole pin of the state, the worse verdict kept for a uri pinned twice. */
|
|
571
|
+
export function syncPinsText(state: SyncState): string {
|
|
572
|
+
const entries = Object.values(state.entries).flatMap((e) => e.plugins.flatMap((p) => (p.pin === undefined ? [] : [p.pin])));
|
|
573
|
+
return pinsFile(entries, "plugin-qualify-sync").text;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** The pin lines, by uri. */
|
|
577
|
+
function pinLinesOf(text: string): Map<string, string> {
|
|
578
|
+
const out = new Map<string, string>();
|
|
579
|
+
for (const line of text.split("\n")) if (line !== "" && !line.startsWith("#")) out.set(line.split(" ")[1]!, line);
|
|
580
|
+
return out;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** The pin lines of `after` that `before` does not hold as they are. */
|
|
584
|
+
export function changedPinLines(before: string, after: string): string[] {
|
|
585
|
+
const was = pinLinesOf(before);
|
|
586
|
+
return [...pinLinesOf(after).entries()].filter(([uri, line]) => was.get(uri) !== line).map(([, line]) => line);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** What became of the pins sent to a daemon: its replies, or why none came. */
|
|
590
|
+
export type SentPins =
|
|
591
|
+
| { readonly replies: readonly string[] }
|
|
592
|
+
/** `off`: hostd_port is 0. `absent`: nothing listens on the port. `held`: the port took the connection and no reply
|
|
593
|
+
* came: plugin-hostd serves one controller at a time, and one is connected. */
|
|
594
|
+
| { readonly unsent: "off" | "absent" | "held" };
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* The lines sent to plugin-hostd, one reply awaited for each, connected as a controller connects: the feedback port
|
|
598
|
+
* first, then the command port, since the daemon answers commands only while its feedback port has a controller. Pins
|
|
599
|
+
* that could not be sent apply at the daemon's next start, from the pin file.
|
|
600
|
+
*/
|
|
601
|
+
export async function sendPins(port: number, feedbackPort: number, lines: readonly string[], timeoutMs = 5000): Promise<SentPins> {
|
|
602
|
+
if (lines.length === 0) return { replies: [] };
|
|
603
|
+
if (port === 0) return { unsent: "off" };
|
|
604
|
+
return new Promise((done) => {
|
|
605
|
+
const feedback = createConnection({ host: "127.0.0.1", port: feedbackPort });
|
|
606
|
+
const socket = createConnection({ host: "127.0.0.1", port });
|
|
607
|
+
const replies: string[] = [];
|
|
608
|
+
let buffer = "";
|
|
609
|
+
const timer = setTimeout(() => finish({ unsent: "held" }), timeoutMs);
|
|
610
|
+
const finish = (result: SentPins): void => {
|
|
611
|
+
clearTimeout(timer);
|
|
612
|
+
socket.destroy();
|
|
613
|
+
feedback.destroy();
|
|
614
|
+
done(result);
|
|
615
|
+
};
|
|
616
|
+
// the daemon's events on the feedback port are not this run's to read: they are drained and dropped
|
|
617
|
+
feedback.on("data", () => {});
|
|
618
|
+
feedback.once("error", () => finish({ unsent: "absent" }));
|
|
619
|
+
socket.setEncoding("utf8");
|
|
620
|
+
socket.once("error", () => finish({ unsent: "absent" }));
|
|
621
|
+
socket.once("connect", () => socket.write(`${lines[0]}\0`));
|
|
622
|
+
socket.on("data", (d: string) => {
|
|
623
|
+
buffer += d;
|
|
624
|
+
for (let nul = buffer.indexOf("\0"); nul >= 0; nul = buffer.indexOf("\0")) {
|
|
625
|
+
replies.push(buffer.slice(0, nul));
|
|
626
|
+
buffer = buffer.slice(nul + 1);
|
|
627
|
+
if (replies.length === lines.length) return finish({ replies });
|
|
628
|
+
socket.write(`${lines[replies.length]}\0`);
|
|
629
|
+
}
|
|
630
|
+
});
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// --- waiting for quiet ------------------------------------------------------------------------------------------------
|
|
635
|
+
|
|
636
|
+
/** What the plugin directories look like from outside: each entry's name, size and modification time, two levels down. */
|
|
637
|
+
export function directoriesSignature(dirs: readonly string[]): string {
|
|
638
|
+
const parts: string[] = [];
|
|
639
|
+
const walk = (dir: string, depth: number): void => {
|
|
640
|
+
let names: string[];
|
|
641
|
+
try {
|
|
642
|
+
names = readdirSync(dir).sort();
|
|
643
|
+
} catch {
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
for (const name of names) {
|
|
647
|
+
const path = join(dir, name);
|
|
648
|
+
try {
|
|
649
|
+
const st = lstatSync(path);
|
|
650
|
+
parts.push(`${path}\t${String(st.size)}\t${String(st.mtimeMs)}`);
|
|
651
|
+
if (depth > 0 && st.isDirectory()) walk(path, depth - 1);
|
|
652
|
+
} catch {
|
|
653
|
+
// gone between the listing and the stat: the next look sees it
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
};
|
|
657
|
+
for (const dir of dirs) walk(dir, 1);
|
|
658
|
+
return createHash("sha256").update(parts.join("\n")).digest("hex");
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
|
662
|
+
|
|
663
|
+
/** Returns once the directories have not changed for `quietS` seconds: a package install writes many files, and one run is enough. */
|
|
664
|
+
export async function waitQuiet(dirs: readonly string[], quietS: number, log: (line: string) => void): Promise<void> {
|
|
665
|
+
if (quietS <= 0) return;
|
|
666
|
+
const pollMs = Math.min(5000, quietS * 1000);
|
|
667
|
+
let seen = directoriesSignature(dirs);
|
|
668
|
+
let since = Date.now();
|
|
669
|
+
while (Date.now() - since < quietS * 1000) {
|
|
670
|
+
await sleep(pollMs);
|
|
671
|
+
const now = directoriesSignature(dirs);
|
|
672
|
+
if (now !== seen) {
|
|
673
|
+
log(`the plugin directories changed; waiting for ${String(quietS)} s of quiet`);
|
|
674
|
+
seen = now;
|
|
675
|
+
since = Date.now();
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// --- the run ----------------------------------------------------------------------------------------------------------
|
|
681
|
+
|
|
682
|
+
export interface SyncSummary {
|
|
683
|
+
readonly qualified: number;
|
|
684
|
+
readonly skipped: number;
|
|
685
|
+
readonly refused: number;
|
|
686
|
+
readonly crashed: number;
|
|
687
|
+
readonly hung: number;
|
|
688
|
+
readonly unmeasured: number;
|
|
689
|
+
readonly seconds: number;
|
|
690
|
+
/** The pins a running daemon took, when one answered. */
|
|
691
|
+
readonly sent?: number;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
export function summaryLine(s: SyncSummary): string {
|
|
695
|
+
return `qualified ${String(s.qualified)}, skipped ${String(s.skipped)}, refused ${String(s.refused)}, crashed ${String(s.crashed)}, hung ${String(s.hung)}, unmeasured ${String(s.unmeasured)}, ${s.seconds.toFixed(1)} s`;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/** The lock of one state file: a second sync on it while one runs returns at once. A lock whose process is gone is taken over. */
|
|
699
|
+
function takeLock(stateFile: string): (() => void) | undefined {
|
|
700
|
+
const lock = `${stateFile}.lock`;
|
|
701
|
+
mkdirSync(dirname(lock), { recursive: true });
|
|
702
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
703
|
+
try {
|
|
704
|
+
const fd = openSync(lock, "wx");
|
|
705
|
+
writeSync(fd, String(process.pid));
|
|
706
|
+
closeSync(fd);
|
|
707
|
+
return () => rmSync(lock, { force: true });
|
|
708
|
+
} catch (e) {
|
|
709
|
+
if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e;
|
|
710
|
+
const pid = Number(readFileSync(lock, "utf8"));
|
|
711
|
+
try {
|
|
712
|
+
if (Number.isInteger(pid) && pid > 0) {
|
|
713
|
+
process.kill(pid, 0);
|
|
714
|
+
return undefined;
|
|
715
|
+
}
|
|
716
|
+
} catch {
|
|
717
|
+
// its process is gone
|
|
718
|
+
}
|
|
719
|
+
unlinkSync(lock);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
return undefined;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
export interface SyncRunOptions {
|
|
726
|
+
readonly force?: boolean;
|
|
727
|
+
readonly env?: Readonly<Record<string, string | undefined>>;
|
|
728
|
+
readonly log?: (line: string) => void;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* One run: wait for quiet, qualify what is new or changed, drop what is gone, write the state, the records, the
|
|
733
|
+
* descriptors and the pin file, and send the changed pins to a running daemon. The state and the pin file are written
|
|
734
|
+
* whole after every item, so a run cut short keeps all it finished. `undefined` when another run holds the lock.
|
|
735
|
+
*/
|
|
736
|
+
export async function runSync(settings: SyncSettings, options: SyncRunOptions = {}): Promise<SyncSummary | undefined> {
|
|
737
|
+
const log = options.log ?? (() => {});
|
|
738
|
+
const env = options.env ?? process.env;
|
|
739
|
+
const unlock = takeLock(settings.stateFile);
|
|
740
|
+
if (unlock === undefined) {
|
|
741
|
+
log(`another sync holds ${settings.stateFile}.lock; this one does nothing`);
|
|
742
|
+
return undefined;
|
|
743
|
+
}
|
|
744
|
+
try {
|
|
745
|
+
const started = Date.now();
|
|
746
|
+
const dirs = [...settings.clapDirs, ...settings.lv2Dirs];
|
|
747
|
+
await waitQuiet(dirs, settings.settleS, log);
|
|
748
|
+
const ctx: SyncContext = { settings, env, log };
|
|
749
|
+
const count = { qualified: 0, skipped: 0, refused: 0, crashed: 0, hung: 0, unmeasured: 0 };
|
|
750
|
+
let before: string;
|
|
751
|
+
try {
|
|
752
|
+
before = readFileSync(settings.pinFile, "utf8");
|
|
753
|
+
} catch {
|
|
754
|
+
before = "";
|
|
755
|
+
}
|
|
756
|
+
let state = readSyncState(settings.stateFile, log);
|
|
757
|
+
const lv2Pinned: string[] = [];
|
|
758
|
+
// a change made while a pass ran is caught by one more pass, a few at most
|
|
759
|
+
for (let pass = 0; pass < 3; pass++) {
|
|
760
|
+
const signature = directoriesSignature(dirs);
|
|
761
|
+
const { items, problems } = discoverSyncItems(settings);
|
|
762
|
+
for (const p of problems) log(p);
|
|
763
|
+
const found = new Set(items.map((i) => i.key));
|
|
764
|
+
const entries: Record<string, SyncEntry> = {};
|
|
765
|
+
for (const [key, entry] of Object.entries(state.entries)) if (found.has(key)) entries[key] = entry;
|
|
766
|
+
for (const key of Object.keys(state.entries)) if (!found.has(key)) log(`${key}: gone, its pin is dropped`);
|
|
767
|
+
for (const item of items) {
|
|
768
|
+
const recorded = recordedAsIs(item, entries[item.key]);
|
|
769
|
+
if (recorded.same && options.force !== true) {
|
|
770
|
+
if (pass === 0) count.skipped += entries[item.key]!.plugins.length;
|
|
771
|
+
continue;
|
|
772
|
+
}
|
|
773
|
+
const files = recorded.files ?? digestFiles(item.root, item.files);
|
|
774
|
+
if (files === undefined) {
|
|
775
|
+
log(`${item.key}: a file of it cannot be read; skipped this run`);
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
const entry = qualifyItem(ctx, item, files);
|
|
779
|
+
entries[item.key] = entry;
|
|
780
|
+
if (item.format === "lv2") lv2Pinned.push(...entry.plugins.filter((p) => p.pin !== undefined).map((p) => p.uri));
|
|
781
|
+
for (const p of entry.plugins) {
|
|
782
|
+
count[p.outcome]++;
|
|
783
|
+
log(`${p.uri}: ${p.outcome}${p.code === undefined ? "" : ` (${p.code})`}${p.detail === undefined ? "" : `: ${p.detail}`}`);
|
|
784
|
+
}
|
|
785
|
+
state = { schema: SYNC_STATE_SCHEMA, entries };
|
|
786
|
+
writeFileWhole(settings.stateFile, `${JSON.stringify(state, null, 1)}\n`);
|
|
787
|
+
writeFileWhole(settings.pinFile, syncPinsText(state));
|
|
788
|
+
}
|
|
789
|
+
state = { schema: SYNC_STATE_SCHEMA, entries };
|
|
790
|
+
if (directoriesSignature(dirs) === signature) break;
|
|
791
|
+
log("the plugin directories changed during the run; looking again");
|
|
792
|
+
}
|
|
793
|
+
writeFileWhole(settings.stateFile, `${JSON.stringify(state, null, 1)}\n`);
|
|
794
|
+
const after = syncPinsText(state);
|
|
795
|
+
if (after !== before) writeFileWhole(settings.pinFile, after);
|
|
796
|
+
writeLv2Descriptors(ctx, lv2Pinned);
|
|
797
|
+
const changed = changedPinLines(before, after);
|
|
798
|
+
const sent = await sendPins(settings.hostdPort, settings.hostdFeedbackPort, changed);
|
|
799
|
+
if ("unsent" in sent) {
|
|
800
|
+
const why = {
|
|
801
|
+
off: "hostd_port is 0",
|
|
802
|
+
absent: `no plugin-hostd listens on port ${String(settings.hostdPort)}`,
|
|
803
|
+
held: `plugin-hostd on port ${String(settings.hostdPort)} did not answer: a controller holds its command socket`,
|
|
804
|
+
}[sent.unsent];
|
|
805
|
+
log(`${why}; the ${String(changed.length)} changed pins apply at its next start, from the pin file`);
|
|
806
|
+
}
|
|
807
|
+
const replies = "replies" in sent ? sent.replies : [];
|
|
808
|
+
for (let i = 0; i < replies.length; i++) if (replies[i] !== "resp 0") log(`plugin-hostd answered ${replies[i]!} to ${changed[i]!.split(" ")[1]!}`);
|
|
809
|
+
const taken = replies.filter((r) => r === "resp 0").length;
|
|
810
|
+
return { ...count, seconds: (Date.now() - started) / 1000, ...(taken === 0 ? {} : { sent: taken }) };
|
|
811
|
+
} finally {
|
|
812
|
+
unlock();
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/** The descriptors of the LV2 plugins qualified this run, in one pass of the LV2 scanner; a failure is named. */
|
|
817
|
+
function writeLv2Descriptors(ctx: SyncContext, uris: readonly string[]): void {
|
|
818
|
+
if (uris.length === 0) return;
|
|
819
|
+
const out = mkdtempSync(join(tmpdir(), "plugin-qualify-sync-scan-"));
|
|
820
|
+
try {
|
|
821
|
+
const r = spawnSync(
|
|
822
|
+
"python3",
|
|
823
|
+
[join(toolsDir(), "scan.py"), "--descriptor-dir", ctx.settings.descriptorDir, "--out", join(out, "catalog.json"), ...uris],
|
|
824
|
+
{ encoding: "utf8", timeout: ctx.settings.budgetS * 1000 * Math.max(1, Math.ceil(uris.length / 10)), env: { ...ctx.env, LV2_PATH: ctx.settings.lv2Dirs.join(":") } },
|
|
825
|
+
);
|
|
826
|
+
if (r.status !== 0) ctx.log(`LV2 descriptors not all written (${r.error?.message ?? `exit ${String(r.status)}`}): ${(r.stderr ?? "").trim().split("\n").slice(-3).join("; ")}`);
|
|
827
|
+
} finally {
|
|
828
|
+
rmSync(out, { recursive: true, force: true });
|
|
829
|
+
}
|
|
830
|
+
}
|