@openmixer/plugin-qualify 0.17.2 → 0.17.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -93,13 +93,15 @@ export function unmeasuredFigures(output: ProbeOutput): readonly UnmeasuredFigur
93
93
 
94
94
  /** A cell's latency when the run took no figure: the contract's unmeasured word and the hosting code a reader classifies by. */
95
95
  export interface UnmeasuredLatency {
96
+ /** The figure the plugin declared (LV2 latency port, CLAP latency extension) when it loaded but the probe did not measure. */
97
+ readonly declaredFrames?: number;
96
98
  readonly unmeasuredReason: string;
97
99
  readonly code: string;
98
100
  }
99
101
 
100
102
  /** A grid point as the record writes it: every cell carries `latency`, the figures or why there are none (#201). */
101
103
  export type RecordGridPoint = Omit<ProbeGridPoint, "latency"> & {
102
- readonly latency: NonNullable<ProbeGridPoint["latency"]> | UnmeasuredLatency;
104
+ readonly latency: (Omit<NonNullable<ProbeGridPoint["latency"]>, "measuredFrames"> & { readonly measuredFrames: number }) | UnmeasuredLatency;
103
105
  };
104
106
 
105
107
  const UNMEASURED_WORDS: ReadonlySet<string> = new Set(Object.values(MEASUREMENT_UNMEASURED_REASONS).map((r) => r.word));
@@ -120,7 +122,13 @@ const OUTCOME_WORD: Readonly<Record<string, string>> = {
120
122
  * older than the latency arm writes it.
121
123
  */
122
124
  export function latencyOfCell(output: ProbeOutput, point: ProbeGridPoint): RecordGridPoint["latency"] {
123
- if (point.latency !== undefined) return point.latency;
125
+ const had = point.latency;
126
+ if (had?.measuredFrames !== undefined) return { ...had, measuredFrames: had.measuredFrames };
127
+ const why = unmeasuredOfCell(output, point);
128
+ return had === undefined ? why : { declaredFrames: had.declaredFrames, ...why };
129
+ }
130
+
131
+ function unmeasuredOfCell(output: ProbeOutput, point: ProbeGridPoint): UnmeasuredLatency {
124
132
  const code = HOSTING_CODE.latencyUnmeasured;
125
133
  if (point.outcome !== PROBE_OK) return { unmeasuredReason: OUTCOME_WORD[point.outcome] ?? word("probeError"), code };
126
134
  const step = output.steps.find((st) => st.step === "latency" && st.rate === point.rate && (st.quantum === undefined || st.quantum === point.quantum));
@@ -162,6 +170,11 @@ export interface QualificationRecord {
162
170
  readonly testedVersion?: string;
163
171
  readonly binary: ProbeOutput["binary"];
164
172
  readonly verdicts: readonly RecordVerdict[];
173
+ /**
174
+ * An LV2 plugin run under plugin-hostd's LV2 adapter (omx-clap-host), as under mod-host: admitted, or refused with
175
+ * its code, or `measured: false` when it was not tried. Beside the verdicts, which it does not change (#223).
176
+ */
177
+ readonly adapter?: { readonly host: string; readonly measured: boolean; readonly admitted: boolean; readonly code?: string; readonly params: Readonly<Record<string, string | number>> };
165
178
  readonly tiers: RecordTiers;
166
179
  /** The cost reasons at each host's rate and quantum, beside the verdicts; an instrument's with the notes it held. */
167
180
  readonly cost: readonly {
@@ -266,6 +279,7 @@ export function buildQualificationRecord(
266
279
  ...(h.verdict.deciding === undefined ? {} : { deciding: h.verdict.deciding }),
267
280
  reasons: h.reasons,
268
281
  })),
282
+ ...(judgment.adapter === undefined ? {} : { adapter: judgment.adapter }),
269
283
  tiers: tiersOf(judgment),
270
284
  cost: judgment.hosts.map((h) => ({ host: h.verdict.host, ...h.cost })),
271
285
  measurements: {
@@ -16,9 +16,9 @@
16
16
  * When a daemon answers on the command port, the pins that changed are also sent to it with `pin_set`, and the ones
17
17
  * of a plugin that is gone with `pin_clear`. What a gone plugin left in the records and the descriptors is removed.
18
18
  */
19
- import { spawnSync } from "node:child_process";
19
+ import { spawn, spawnSync } from "node:child_process";
20
20
  import { createHash } from "node:crypto";
21
- import { closeSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync, writeSync } from "node:fs";
21
+ import { closeSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
22
22
  import { createConnection } from "node:net";
23
23
  import { homedir, tmpdir } from "node:os";
24
24
  import { basename, dirname, join, resolve } from "node:path";
@@ -30,6 +30,7 @@ import { HOSTING_CODE } from "./hosting-codes.js";
30
30
  import { shippedHostProfile, type HostProfile } from "./host-profiles.js";
31
31
  import { lv2BundlesOnPath, manifestPlugins } from "./lv2-loader.js";
32
32
  import { judgmentPinEntries, pinsFile, type PinEntry } from "./pins-file.js";
33
+ import { hostdPinFiles, type PinFilesRun } from "./hostd-pin-files.js";
33
34
  import type { Judgment } from "./probe-judge.js";
34
35
  import { qualifyTargets } from "./probe-runner.js";
35
36
  import { GRID_FULL, parseGridSpec, type GridSpec } from "./measurement-grid.js";
@@ -124,6 +125,7 @@ export interface SyncArgs {
124
125
  readonly system: boolean;
125
126
  readonly force: boolean;
126
127
  readonly noDistributed: boolean;
128
+ readonly noWait: boolean;
127
129
  readonly help: boolean;
128
130
  /** The settings given as flags, by key. */
129
131
  readonly values: ReadonlyMap<string, string>;
@@ -138,12 +140,14 @@ export function parseSyncArgs(argv: readonly string[]): { ok: true; args: SyncAr
138
140
  let system = false;
139
141
  let force = false;
140
142
  let noDistributed = false;
143
+ let noWait = false;
141
144
  let help = false;
142
145
  for (let i = 0; i < argv.length; i++) {
143
146
  const a = argv[i]!;
144
147
  if (a === "--system") system = true;
145
148
  else if (a === "--force") force = true;
146
149
  else if (a === "--no-distributed") noDistributed = true;
150
+ else if (a === "--no-wait") noWait = true;
147
151
  else if (a === "--help") help = true;
148
152
  else if (a === "--grid-hosts") values.set("grid", "hosts");
149
153
  else if (a === "--config" || byFlag.has(a)) {
@@ -153,7 +157,7 @@ export function parseSyncArgs(argv: readonly string[]): { ok: true; args: SyncAr
153
157
  else values.set(byFlag.get(a)!, v);
154
158
  } else return { ok: false, error: `unknown argument ${a}` };
155
159
  }
156
- return { ok: true, args: { ...(config === undefined ? {} : { config }), system, force, noDistributed, help, values } };
160
+ return { ok: true, args: { ...(config === undefined ? {} : { config }), system, force, noDistributed, noWait, help, values } };
157
161
  }
158
162
 
159
163
  export function syncHelpText(): string {
@@ -164,6 +168,7 @@ export function syncHelpText(): string {
164
168
  ` ${"--config <file>".padEnd(36)}the settings file (default: ${SYNC_CONF}; a missing file is the defaults)`,
165
169
  " --system a system service's directories, under /var/lib/omx",
166
170
  " --force qualify every plugin again, recorded or not; no distributed record is read",
171
+ " --no-wait a run in progress is not waited for: exit 0, and the holder runs once more at its end",
167
172
  " --no-distributed read no distributed record: every plugin not recorded is measured",
168
173
  " --grid-hosts the fast grid, the same as --grid hosts (7 cells with the shipped hosts)",
169
174
  " --help this text",
@@ -491,6 +496,23 @@ export interface SyncContext {
491
496
  readonly settings: SyncSettings;
492
497
  readonly env: Readonly<Record<string, string | undefined>>;
493
498
  readonly log: (line: string) => void;
499
+ /** The run of `plugin-hostd --pin-files`, a stand-in in tests. */
500
+ readonly pinFilesRun?: PinFilesRun;
501
+ }
502
+
503
+ /**
504
+ * The pin of a judged run with the files plugin-hostd collects for its plugin (plugin-qualify#228): the probe's list is
505
+ * the probe's view, and an add checks the daemon's, so a pin holds the daemon's. No pin when plugin-hostd cannot name
506
+ * them; the plugin is then pinned on its first add (pin_mode first-use), and the log says why.
507
+ */
508
+ function withHostdFiles(ctx: SyncContext, pin: PinEntry | undefined): PinEntry | undefined {
509
+ if (pin === undefined || pin.integrity.files === undefined) return pin;
510
+ const r = hostdPinFiles(pin.uri, ctx.settings.lv2Dirs, ctx.env, ...(ctx.pinFilesRun === undefined ? [] : [ctx.pinFilesRun]));
511
+ if (!r.ok) {
512
+ ctx.log(`${pin.uri}: not pinned: ${r.detail}`);
513
+ return undefined;
514
+ }
515
+ return { ...pin, integrity: { ...pin.integrity, files: r.files } };
494
516
  }
495
517
 
496
518
  /** A judged run's outcome for the host: qualified, refused, or why it was not measured. */
@@ -528,7 +550,7 @@ function judgedPlugins(ctx: SyncContext, runs: readonly { bytes: Buffer; judgmen
528
550
  const host = ctx.settings.hostProfile.id;
529
551
  const entries = runs.map(({ bytes, judgment }) => {
530
552
  const { recordPath } = writeQualification(ctx.settings.qualificationDir, bytes, judgment, QUICK_NOT_MEASURED);
531
- const pin = judgmentPinEntries(judgment, host)[0];
553
+ const pin = withHostdFiles(ctx, judgmentPinEntries(judgment, host)[0]);
532
554
  const { outcome, code } = outcomeOf(judgment, host);
533
555
  const plugin: SyncPlugin = { uri: pin?.uri ?? judgment.output.plugin.id, outcome, ...(code === undefined ? {} : { code }), ...(pin === undefined ? {} : { pin }), record: recordPath };
534
556
  return { plugin, judgedHere: judgment.hosts.some((h) => h.verdict.host === host) };
@@ -550,6 +572,8 @@ function runPass(ctx: SyncContext, target: Parameters<typeof qualifyTargets>[0][
550
572
  profiles: [s.hostProfile],
551
573
  wallS: s.budgetS,
552
574
  env,
575
+ // mod-host and jalv are admitted on the device's LV2 directories, not on the one-bundle world the probe measures in
576
+ admissionLv2Path: s.lv2Dirs,
553
577
  });
554
578
  }
555
579
 
@@ -919,57 +943,143 @@ export interface SyncSummary {
919
943
  readonly sent?: number;
920
944
  }
921
945
 
946
+ /**
947
+ * Why a reader that is not the sync's user (the console, #221) cannot read the descriptors: the directory or one of its
948
+ * parent without search permission for others, the directory or a descriptor without read permission for others. One
949
+ * line each; none when the directory does not exist yet or everything is open.
950
+ */
951
+ export function descriptorAccessProblems(dir: string): string[] {
952
+ const problems: string[] = [];
953
+ const mode = (path: string): number | undefined => {
954
+ try {
955
+ return statSync(path).mode & 0o777;
956
+ } catch {
957
+ return undefined;
958
+ }
959
+ };
960
+ const octal = (m: number): string => `0${m.toString(8)}`;
961
+ const top = mode(dir);
962
+ if (top === undefined) return problems;
963
+ // the parent too (StateDirectory's /var/lib/omx); not further up, where a private home is a user's own choice
964
+ const parent = dirname(resolve(dir));
965
+ const parentMode = mode(parent);
966
+ if (parentMode !== undefined && (parentMode & 0o001) === 0) problems.push(`${parent} is mode ${octal(parentMode)}: other users, the console among them, cannot enter it to read the descriptors`);
967
+ if ((top & 0o004) === 0) problems.push(`${dir} is mode ${octal(top)}: other users, the console among them, cannot list the descriptors`);
968
+ let files: string[] = [];
969
+ try {
970
+ files = readdirSync(dir).filter((f) => f.endsWith(".json"));
971
+ } catch {
972
+ return problems;
973
+ }
974
+ const closed = files.filter((f) => ((mode(join(dir, f)) ?? 0o004) & 0o004) === 0);
975
+ if (closed.length > 0) problems.push(`${String(closed.length)} descriptor(s) in ${dir} cannot be read by other users, the console among them (first: ${closed[0]!})`);
976
+ return problems;
977
+ }
978
+
922
979
  export function summaryLine(s: SyncSummary): string {
923
980
  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)}, distributed ${String(s.distributed)}, ${s.seconds.toFixed(1)} s`;
924
981
  }
925
982
 
926
- /** 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. */
927
- function takeLock(stateFile: string): (() => void) | undefined {
983
+ /** The exit status `flock -n` returns when the lock is held (-E), told apart from a failure of flock itself. */
984
+ const LOCK_HELD = 99;
985
+
986
+ export interface SyncLock {
987
+ release(): Promise<void>;
988
+ }
989
+
990
+ /**
991
+ * The lock of one state file: flock(1) on `<state file>.lock`, held by a child process for as long as this one lives or
992
+ * until `release`, so a crashed run frees it. Waits for the holder, or with `wait` false returns `undefined` at once.
993
+ */
994
+ export function takeLock(stateFile: string, wait: boolean): Promise<SyncLock | undefined> {
928
995
  const lock = `${stateFile}.lock`;
929
996
  mkdirSync(dirname(lock), { recursive: true });
930
- for (let attempt = 0; attempt < 2; attempt++) {
931
- try {
932
- const fd = openSync(lock, "wx");
933
- writeSync(fd, String(process.pid));
934
- closeSync(fd);
935
- return () => rmSync(lock, { force: true });
936
- } catch (e) {
937
- if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e;
938
- const pid = Number(readFileSync(lock, "utf8"));
939
- try {
940
- if (Number.isInteger(pid) && pid > 0) {
941
- process.kill(pid, 0);
942
- return undefined;
943
- }
944
- } catch {
945
- // its process is gone
946
- }
947
- unlinkSync(lock);
948
- }
949
- }
950
- return undefined;
997
+ const child = spawn("flock", ["-x", "-E", String(LOCK_HELD), ...(wait ? [] : ["-n"]), lock, "sh", "-c", "echo locked; exec cat"], { stdio: ["pipe", "pipe", "inherit"] });
998
+ return new Promise((resolvePromise, reject) => {
999
+ const exited = new Promise<void>((done) => child.once("close", () => done()));
1000
+ child.once("error", reject);
1001
+ child.stdout.once("data", () =>
1002
+ resolvePromise({
1003
+ release: () => {
1004
+ child.stdin.end();
1005
+ return exited;
1006
+ },
1007
+ }),
1008
+ );
1009
+ child.once("close", (code) => {
1010
+ if (code === LOCK_HELD) resolvePromise(undefined);
1011
+ else reject(new Error(`flock ${lock} failed (exit ${String(code)})`));
1012
+ });
1013
+ });
951
1014
  }
952
1015
 
1016
+ /** What a second run leaves for the holder: one more run at its end, over what changed meanwhile. */
1017
+ const againMarker = (stateFile: string): string => `${stateFile}.again`;
1018
+
953
1019
  export interface SyncRunOptions {
954
1020
  readonly force?: boolean;
1021
+ /** False under `--no-wait`: a run in progress is not waited for; the holder runs once more at its end instead. */
1022
+ readonly wait?: boolean;
955
1023
  readonly env?: Readonly<Record<string, string | undefined>>;
956
1024
  readonly log?: (line: string) => void;
957
1025
  }
958
1026
 
959
1027
  /**
960
- * One run: wait for quiet, qualify what is new or changed, drop what is gone, write the state, the records, the
961
- * descriptors and the pin file, and send the changed pins to a running daemon. The state and the pin file are written
962
- * whole after every item, so a run cut short keeps all it finished. `undefined` when another run holds the lock.
1028
+ * One run under the lock: waits for a run in progress, or with `wait` false leaves a marker for it and returns
1029
+ * `undefined`. The holder runs once more at its end for every marker left meanwhile.
963
1030
  */
964
1031
  export async function runSync(settings: SyncSettings, options: SyncRunOptions = {}): Promise<SyncSummary | undefined> {
965
1032
  const log = options.log ?? (() => {});
966
- const env = options.env ?? process.env;
967
- const unlock = takeLock(settings.stateFile);
968
- if (unlock === undefined) {
969
- log(`another sync holds ${settings.stateFile}.lock; this one does nothing`);
970
- return undefined;
1033
+ const marker = againMarker(settings.stateFile);
1034
+ let lock = await takeLock(settings.stateFile, options.wait !== false);
1035
+ if (lock === undefined) {
1036
+ // the marker first, then the lock once more: a holder that finished in between is not left without a successor
1037
+ writeFileSync(marker, "");
1038
+ lock = await takeLock(settings.stateFile, false);
1039
+ if (lock === undefined) {
1040
+ log(`a run is in progress (${settings.stateFile}.lock); it runs once more at its end over what changed`);
1041
+ return undefined;
1042
+ }
971
1043
  }
972
1044
  try {
1045
+ let total: SyncSummary | undefined;
1046
+ for (;;) {
1047
+ rmSync(marker, { force: true });
1048
+ const one = await runLocked(settings, options);
1049
+ total = total === undefined ? one : addSummaries(total, one);
1050
+ if (!existsSync(marker)) return total;
1051
+ log("a run was asked for while this one ran; running once more");
1052
+ }
1053
+ } finally {
1054
+ await lock.release();
1055
+ }
1056
+ }
1057
+
1058
+ function addSummaries(a: SyncSummary, b: SyncSummary): SyncSummary {
1059
+ const sent = (a.sent ?? 0) + (b.sent ?? 0);
1060
+ return {
1061
+ qualified: a.qualified + b.qualified,
1062
+ skipped: a.skipped + b.skipped,
1063
+ refused: a.refused + b.refused,
1064
+ crashed: a.crashed + b.crashed,
1065
+ hung: a.hung + b.hung,
1066
+ unmeasured: a.unmeasured + b.unmeasured,
1067
+ distributed: a.distributed + b.distributed,
1068
+ seconds: a.seconds + b.seconds,
1069
+ ...(sent === 0 ? {} : { sent }),
1070
+ };
1071
+ }
1072
+
1073
+ /**
1074
+ * One run: wait for quiet, qualify what is new or changed, drop what is gone, write the state, the records, the
1075
+ * descriptors and the pin file, and send the changed pins to a running daemon. The state and the pin file are written
1076
+ * whole after every item, so a run cut short keeps all it finished.
1077
+ */
1078
+ async function runLocked(settings: SyncSettings, options: SyncRunOptions): Promise<SyncSummary> {
1079
+ const log = options.log ?? (() => {});
1080
+ const env = options.env ?? process.env;
1081
+ log("sync run begins");
1082
+ {
973
1083
  const started = Date.now();
974
1084
  const dirs = [...settings.clapDirs, ...settings.lv2Dirs];
975
1085
  await waitQuiet(dirs, settings.settleS, log);
@@ -1037,6 +1147,7 @@ export async function runSync(settings: SyncSettings, options: SyncRunOptions =
1037
1147
  const after = syncPinsText(state);
1038
1148
  if (after !== before) writeFileWhole(settings.pinFile, after);
1039
1149
  writeLv2Descriptors(ctx, lv2Pinned);
1150
+ for (const line of descriptorAccessProblems(settings.descriptorDir)) log(line);
1040
1151
  // the files of a plugin that is gone, or was replaced by other bytes: only when every directory could be read, so
1041
1152
  // an unreadable one is never taken for an uninstall
1042
1153
  if (unreadable) log("a plugin directory could not be read; no record or descriptor is removed this run");
@@ -1056,8 +1167,6 @@ export async function runSync(settings: SyncSettings, options: SyncRunOptions =
1056
1167
  for (let i = 0; i < replies.length; i++) if (replies[i] !== "resp 0") log(`plugin-hostd answered ${replies[i]!} to ${changed[i]!.split(" ")[1]!}`);
1057
1168
  const taken = replies.filter((r) => r === "resp 0").length;
1058
1169
  return { ...count, seconds: (Date.now() - started) / 1000, ...(taken === 0 ? {} : { sent: taken }) };
1059
- } finally {
1060
- unlock();
1061
1170
  }
1062
1171
  }
1063
1172
 
@@ -138,16 +138,23 @@ class SchemaValidator:
138
138
  raise DescriptorRefused(DESCRIPTOR_CODE_SCHEMA, f"{where}: {e.message}")
139
139
 
140
140
 
141
+ DESCRIPTOR_DIR_MODE = 0o755
142
+ DESCRIPTOR_FILE_MODE = 0o644
143
+
144
+
141
145
  def write_descriptor(directory, doc, sha256, validator):
142
146
  """Hold `doc` to the schema and write it whole into `directory`; the path written, or DescriptorRefused."""
143
147
  validator.check(doc)
144
148
  name = descriptor_file_name(doc["plugin"]["id"], doc["plugin"].get("version"), sha256)
145
149
  try:
146
- os.makedirs(directory, mode=0o700, exist_ok=True)
150
+ # the console reads this directory as another user (#221): published world-readable whatever the umask
151
+ os.makedirs(directory, mode=DESCRIPTOR_DIR_MODE, exist_ok=True)
152
+ os.chmod(directory, DESCRIPTOR_DIR_MODE)
147
153
  path = os.path.join(directory, name)
148
154
  pending = path + ".new"
149
155
  with open(pending, "w", encoding="utf-8") as f:
150
156
  f.write(json.dumps(doc, indent=2, ensure_ascii=False) + "\n")
157
+ os.chmod(pending, DESCRIPTOR_FILE_MODE)
151
158
  os.replace(pending, path)
152
159
  except OSError as e:
153
160
  raise DescriptorRefused(DESCRIPTOR_CODE_DIR_UNWRITABLE, f"{directory}: {e.strerror}") from e
@@ -8,6 +8,8 @@ import { spawn } from "node:child_process";
8
8
  import { createConnection } from "node:net";
9
9
 
10
10
  const uri = process.argv[2];
11
+ // the host to admit under: mod-host, or omx-clap-host for plugin-hostd's LV2 adapter, which answers the same protocol
12
+ const bin = process.argv[3] ?? "mod-host";
11
13
  const port = 20000 + (process.pid % 20000);
12
14
  const STEP_MS = 10000;
13
15
  const INSTANCE = 0;
@@ -16,7 +18,7 @@ const COMMANDS = [
16
18
  ["remove", `remove ${INSTANCE}`],
17
19
  ];
18
20
 
19
- const host = spawn("mod-host", ["-n", "-p", String(port)], { stdio: "ignore" });
21
+ const host = spawn(bin, ["-n", "-p", String(port)], { stdio: "ignore" });
20
22
  let hostEnd; // how the host went, once it has
21
23
  host.on("exit", (code, signal) => (hostEnd = signal ?? "exit"));
22
24
  const spawnFailed = new Promise((resolve) => host.on("error", (e) => resolve(e.code)));