@openmixer/plugin-qualify 0.17.4 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +53 -15
  2. package/bin/plugin-qualify-sync.mjs +1 -1
  3. package/dist/clap-qualify.d.ts +3 -1
  4. package/dist/clap-qualify.js +7 -1
  5. package/dist/clap-qualify.js.map +1 -1
  6. package/dist/direct-limit.d.ts +27 -0
  7. package/dist/direct-limit.js +52 -0
  8. package/dist/direct-limit.js.map +1 -0
  9. package/dist/host-admission.d.ts +10 -2
  10. package/dist/host-admission.js +13 -2
  11. package/dist/host-admission.js.map +1 -1
  12. package/dist/host-profiles.d.ts +8 -0
  13. package/dist/host-profiles.js +16 -1
  14. package/dist/host-profiles.js.map +1 -1
  15. package/dist/hosting-codes.generated.d.ts +10 -0
  16. package/dist/hosting-codes.generated.js +10 -0
  17. package/dist/hosting-codes.generated.js.map +1 -1
  18. package/dist/hosting-suitability.d.ts +34 -8
  19. package/dist/hosting-suitability.js +92 -7
  20. package/dist/hosting-suitability.js.map +1 -1
  21. package/dist/index.d.ts +1 -0
  22. package/dist/index.js +1 -0
  23. package/dist/index.js.map +1 -1
  24. package/dist/measurement/format.d.ts +54 -8
  25. package/dist/measurement/format.js +54 -3
  26. package/dist/measurement/format.js.map +1 -1
  27. package/dist/measurement/plan.d.ts +3 -3
  28. package/dist/measurement/plan.js +1 -1
  29. package/dist/measurement-grid.d.ts +8 -0
  30. package/dist/measurement-grid.js +4 -0
  31. package/dist/measurement-grid.js.map +1 -1
  32. package/dist/memory-bound.d.ts +6 -0
  33. package/dist/memory-bound.js +8 -0
  34. package/dist/memory-bound.js.map +1 -1
  35. package/dist/memory-budget.d.ts +39 -0
  36. package/dist/memory-budget.js +32 -0
  37. package/dist/memory-budget.js.map +1 -0
  38. package/dist/probe-judge.js +22 -9
  39. package/dist/probe-judge.js.map +1 -1
  40. package/dist/probe-merge.js +6 -5
  41. package/dist/probe-merge.js.map +1 -1
  42. package/dist/probe-output-fixtures.d.ts +6 -0
  43. package/dist/probe-output-fixtures.js +8 -0
  44. package/dist/probe-output-fixtures.js.map +1 -1
  45. package/dist/probe-output.d.ts +4 -2
  46. package/dist/probe-output.js +2 -1
  47. package/dist/probe-output.js.map +1 -1
  48. package/dist/probe-runner.js +6 -3
  49. package/dist/probe-runner.js.map +1 -1
  50. package/dist/qualification-record.d.ts +26 -2
  51. package/dist/qualification-record.js +17 -18
  52. package/dist/qualification-record.js.map +1 -1
  53. package/dist/qualify-declarations.d.ts +30 -11
  54. package/dist/qualify-declarations.js +38 -2
  55. package/dist/qualify-declarations.js.map +1 -1
  56. package/dist/qualify-sync.d.ts +20 -0
  57. package/dist/qualify-sync.js +101 -10
  58. package/dist/qualify-sync.js.map +1 -1
  59. package/package.json +1 -1
  60. package/src/clap-qualify.ts +8 -1
  61. package/src/direct-limit.ts +68 -0
  62. package/src/host-admission.ts +15 -2
  63. package/src/host-profiles.ts +17 -1
  64. package/src/hosting-codes.generated.ts +10 -0
  65. package/src/hosting-suitability.ts +105 -11
  66. package/src/index.ts +1 -0
  67. package/src/measurement/format.ts +97 -3
  68. package/src/measurement/plan.ts +3 -3
  69. package/src/measurement-grid.ts +5 -0
  70. package/src/memory-bound.ts +10 -0
  71. package/src/memory-budget.ts +55 -0
  72. package/src/probe-judge.ts +21 -8
  73. package/src/probe-merge.ts +6 -5
  74. package/src/probe-output-fixtures.ts +10 -1
  75. package/src/probe-output.ts +5 -2
  76. package/src/probe-runner.ts +5 -3
  77. package/src/qualification-record.ts +22 -4
  78. package/src/qualify-declarations.ts +49 -3
  79. package/src/qualify-sync.ts +103 -9
  80. package/tools/_declarations.py +1 -0
  81. package/tools/benchmark.py +25 -2
  82. package/tools/gen-python-declarations.mjs +1 -0
  83. package/tools/lv2-measure.mjs +7 -4
  84. package/tools/lv2_presets.py +1 -1
  85. package/tools/mod-host-admit.mjs +2 -1
@@ -21,7 +21,7 @@ import { createHash } from "node:crypto";
21
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
- import { basename, dirname, join, resolve } from "node:path";
24
+ import { basename, dirname, join, resolve, sep } from "node:path";
25
25
 
26
26
  import { clapQualifyTools } from "./clap-qualify.js";
27
27
  import { DISTRIBUTED_DIR_SYSTEM, EMPTY_LAYER, applyDistributed, distributedUri, hostClassOfBoard, matchDistributed, readBoardModel, readDistributedLayer, type DistributedLayer } from "./distributed-records.js";
@@ -126,6 +126,8 @@ export interface SyncArgs {
126
126
  readonly force: boolean;
127
127
  readonly noDistributed: boolean;
128
128
  readonly noWait: boolean;
129
+ /** `--refresh-pins`: refresh the pins' files from plugin-hostd and measure nothing (the package's upgrade runs it). */
130
+ readonly refreshPins: boolean;
129
131
  readonly help: boolean;
130
132
  /** The settings given as flags, by key. */
131
133
  readonly values: ReadonlyMap<string, string>;
@@ -141,6 +143,7 @@ export function parseSyncArgs(argv: readonly string[]): { ok: true; args: SyncAr
141
143
  let force = false;
142
144
  let noDistributed = false;
143
145
  let noWait = false;
146
+ let refreshPinsOnly = false;
144
147
  let help = false;
145
148
  for (let i = 0; i < argv.length; i++) {
146
149
  const a = argv[i]!;
@@ -148,6 +151,7 @@ export function parseSyncArgs(argv: readonly string[]): { ok: true; args: SyncAr
148
151
  else if (a === "--force") force = true;
149
152
  else if (a === "--no-distributed") noDistributed = true;
150
153
  else if (a === "--no-wait") noWait = true;
154
+ else if (a === "--refresh-pins") refreshPinsOnly = true;
151
155
  else if (a === "--help") help = true;
152
156
  else if (a === "--grid-hosts") values.set("grid", "hosts");
153
157
  else if (a === "--config" || byFlag.has(a)) {
@@ -157,7 +161,7 @@ export function parseSyncArgs(argv: readonly string[]): { ok: true; args: SyncAr
157
161
  else values.set(byFlag.get(a)!, v);
158
162
  } else return { ok: false, error: `unknown argument ${a}` };
159
163
  }
160
- return { ok: true, args: { ...(config === undefined ? {} : { config }), system, force, noDistributed, noWait, help, values } };
164
+ return { ok: true, args: { ...(config === undefined ? {} : { config }), system, force, noDistributed, noWait, refreshPins: refreshPinsOnly, help, values } };
161
165
  }
162
166
 
163
167
  export function syncHelpText(): string {
@@ -170,6 +174,7 @@ export function syncHelpText(): string {
170
174
  " --force qualify every plugin again, recorded or not; no distributed record is read",
171
175
  " --no-wait a run in progress is not waited for: exit 0, and the holder runs once more at its end",
172
176
  " --no-distributed read no distributed record: every plugin not recorded is measured",
177
+ " --refresh-pins only bring every pin's files up to the ones plugin-hostd names, measuring nothing",
173
178
  " --grid-hosts the fast grid, the same as --grid hosts (7 cells with the shipped hosts)",
174
179
  " --help this text",
175
180
  ...rows,
@@ -515,6 +520,42 @@ function withHostdFiles(ctx: SyncContext, pin: PinEntry | undefined): PinEntry |
515
520
  return { ...pin, integrity: { ...pin.integrity, files: r.files } };
516
521
  }
517
522
 
523
+ /**
524
+ * Every pin of the state with the files plugin-hostd collects for its plugin now, measured again by nothing
525
+ * (plugin-qualify#239). A pin written before plugin-qualify 0.17.3 holds the probe's view of an LV2 (binary, plugin
526
+ * TTL, manifest) and not the preset files plugin-hostd 0.15.0 also checks, so its add answers -509 until the plugin
527
+ * is qualified again: hours on a device with hundreds. The new list replaces the old one when no file both lists
528
+ * hold changed its hash, so the verdict stands for the same bytes; a changed file is the sync's to measure again,
529
+ * and that pin is left as it is. A plugin plugin-hostd cannot name the files of keeps its pin.
530
+ */
531
+ export function refreshPins(ctx: SyncContext, state: SyncState): { state: SyncState; refreshed: string[] } {
532
+ const refreshed: string[] = [];
533
+ const entries: Record<string, SyncEntry> = {};
534
+ for (const [key, entry] of Object.entries(state.entries)) {
535
+ const plugins = entry.plugins.map((plugin) => {
536
+ const pin = plugin.pin;
537
+ if (pin === undefined || pin.integrity.files === undefined) return plugin;
538
+ const r = hostdPinFiles(pin.uri, ctx.settings.lv2Dirs, ctx.env, ...(ctx.pinFilesRun === undefined ? [] : [ctx.pinFilesRun]));
539
+ if (!r.ok) return plugin;
540
+ const old = new Map(pin.integrity.files.map((f) => [f.path, f.sha256]));
541
+ const changed = r.files.filter((f) => old.has(f.path) && old.get(f.path) !== f.sha256).map((f) => f.path);
542
+ const same = r.files.length === old.size && r.files.every((f) => old.get(f.path) === f.sha256);
543
+ if (same) return plugin;
544
+ if (changed.length > 0) {
545
+ ctx.log(`${pin.uri}: ${changed.join(", ")} changed since it was qualified; its pin waits for the sync`);
546
+ return plugin;
547
+ }
548
+ const added = r.files.filter((f) => !old.has(f.path)).map((f) => f.path);
549
+ const gone = [...old.keys()].filter((path) => !r.files.some((f) => f.path === path));
550
+ ctx.log(`${pin.uri}: pin files refreshed${added.length ? `, ${added.join(", ")} added` : ""}${gone.length ? `, ${gone.join(", ")} gone` : ""}`);
551
+ refreshed.push(pin.uri);
552
+ return { ...plugin, pin: { ...pin, integrity: { ...pin.integrity, files: r.files } } };
553
+ });
554
+ entries[key] = refreshed.length === 0 ? entry : { ...entry, plugins };
555
+ }
556
+ return { state: refreshed.length === 0 ? state : { schema: SYNC_STATE_SCHEMA, entries }, refreshed };
557
+ }
558
+
518
559
  /** A judged run's outcome for the host: qualified, refused, or why it was not measured. */
519
560
  function outcomeOf(judgment: Judgment, host: string): { outcome: SyncOutcome; code?: string } {
520
561
  const v = judgment.hosts.find((h) => h.verdict.host === host)?.verdict;
@@ -941,6 +982,8 @@ export interface SyncSummary {
941
982
  readonly seconds: number;
942
983
  /** The pins a running daemon took, when one answered. */
943
984
  readonly sent?: number;
985
+ /** The pins whose files were refreshed from plugin-hostd without a measurement (plugin-qualify#239). */
986
+ readonly refreshed?: number;
944
987
  }
945
988
 
946
989
  /**
@@ -977,7 +1020,7 @@ export function descriptorAccessProblems(dir: string): string[] {
977
1020
  }
978
1021
 
979
1022
  export function summaryLine(s: SyncSummary): string {
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`;
1023
+ 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.refreshed ? `, pins refreshed ${String(s.refreshed)}` : ""}, ${s.seconds.toFixed(1)} s`;
981
1024
  }
982
1025
 
983
1026
  /** The exit status `flock -n` returns when the lock is held (-E), told apart from a failure of flock itself. */
@@ -1021,6 +1064,10 @@ export const pendingMarker = (stateFile: string): string => join(dirname(stateFi
1021
1064
 
1022
1065
  export interface SyncRunOptions {
1023
1066
  readonly force?: boolean;
1067
+ /** `--refresh-pins`: only refresh the pins' files from plugin-hostd and send the changed ones; measure nothing. */
1068
+ readonly refreshOnly?: boolean;
1069
+ /** The run of `plugin-hostd --pin-files`, a stand-in in tests. */
1070
+ readonly pinFilesRun?: PinFilesRun;
1024
1071
  /** False under `--no-wait`: a run in progress is not waited for; the holder runs once more at its end instead. */
1025
1072
  readonly wait?: boolean;
1026
1073
  readonly env?: Readonly<Record<string, string | undefined>>;
@@ -1064,6 +1111,7 @@ export async function runSync(settings: SyncSettings, options: SyncRunOptions =
1064
1111
 
1065
1112
  function addSummaries(a: SyncSummary, b: SyncSummary): SyncSummary {
1066
1113
  const sent = (a.sent ?? 0) + (b.sent ?? 0);
1114
+ const refreshed = (a.refreshed ?? 0) + (b.refreshed ?? 0);
1067
1115
  return {
1068
1116
  qualified: a.qualified + b.qualified,
1069
1117
  skipped: a.skipped + b.skipped,
@@ -1074,9 +1122,19 @@ function addSummaries(a: SyncSummary, b: SyncSummary): SyncSummary {
1074
1122
  distributed: a.distributed + b.distributed,
1075
1123
  seconds: a.seconds + b.seconds,
1076
1124
  ...(sent === 0 ? {} : { sent }),
1125
+ ...(refreshed === 0 ? {} : { refreshed }),
1077
1126
  };
1078
1127
  }
1079
1128
 
1129
+ /** Whether this run's scan covers an entry: its root lies under one of the run's directories of its own format. */
1130
+ function scannedBy(settings: Pick<SyncSettings, "lv2Dirs" | "clapDirs">, entry: SyncEntry): boolean {
1131
+ const root = resolve(entry.root);
1132
+ return (entry.format === "clap" ? settings.clapDirs : settings.lv2Dirs).some((d) => {
1133
+ const dir = resolve(d);
1134
+ return root === dir || root.startsWith(dir.endsWith(sep) ? dir : dir + sep);
1135
+ });
1136
+ }
1137
+
1080
1138
  /**
1081
1139
  * One run: wait for quiet, qualify what is new or changed, drop what is gone, write the state, the records, the
1082
1140
  * descriptors and the pin file, and send the changed pins to a running daemon. The state and the pin file are written
@@ -1085,12 +1143,19 @@ function addSummaries(a: SyncSummary, b: SyncSummary): SyncSummary {
1085
1143
  async function runLocked(settings: SyncSettings, options: SyncRunOptions): Promise<SyncSummary> {
1086
1144
  const log = options.log ?? (() => {});
1087
1145
  const env = options.env ?? process.env;
1088
- log("sync run begins");
1146
+ log(options.refreshOnly === true ? "pin refresh begins" : "sync run begins");
1089
1147
  {
1090
1148
  const started = Date.now();
1091
1149
  const dirs = [...settings.clapDirs, ...settings.lv2Dirs];
1150
+ const ctx: SyncContext = { settings, env, log, ...(options.pinFilesRun === undefined ? {} : { pinFilesRun: options.pinFilesRun }) };
1151
+ // the pins first, before the quiet wait and any measurement: an upgrade of plugin-hostd that collects more files
1152
+ // than the pins on disk hold refuses those plugins until their pins name them (plugin-qualify#239)
1153
+ const fresh = refreshPins(ctx, readSyncState(settings.stateFile, log));
1154
+ if (fresh.refreshed.length > 0) {
1155
+ writeFileWhole(settings.stateFile, `${JSON.stringify(fresh.state, null, 1)}\n`);
1156
+ }
1157
+ if (options.refreshOnly === true) return sendRefreshed(settings, fresh.state, fresh.refreshed.length, started, log);
1092
1158
  await waitQuiet(dirs, settings.settleS, log);
1093
- const ctx: SyncContext = { settings, env, log };
1094
1159
  const count = { qualified: 0, skipped: 0, refused: 0, crashed: 0, hung: 0, unmeasured: 0, distributed: 0 };
1095
1160
  const layer = distributedLayerOf(settings, options.force === true, log);
1096
1161
  let before: string;
@@ -1099,7 +1164,7 @@ async function runLocked(settings: SyncSettings, options: SyncRunOptions): Promi
1099
1164
  } catch {
1100
1165
  before = "";
1101
1166
  }
1102
- let state = readSyncState(settings.stateFile, log);
1167
+ let state = fresh.state;
1103
1168
  const stateBefore = state;
1104
1169
  let unreadable = false;
1105
1170
  const lv2Pinned: string[] = [];
@@ -1111,8 +1176,14 @@ async function runLocked(settings: SyncSettings, options: SyncRunOptions): Promi
1111
1176
  unreadable = problems.length > 0;
1112
1177
  const found = new Set(items.map((i) => i.key));
1113
1178
  const entries: Record<string, SyncEntry> = {};
1114
- for (const [key, entry] of Object.entries(state.entries)) if (found.has(key)) entries[key] = entry;
1115
- for (const key of Object.keys(state.entries)) if (!found.has(key)) log(`${key}: gone, its pin is dropped`);
1179
+ for (const [key, entry] of Object.entries(state.entries)) {
1180
+ if (found.has(key)) entries[key] = entry;
1181
+ // a run scoped to some directories keeps what it did not scan: gone means not found where this run looked
1182
+ else if (!scannedBy(settings, entry)) {
1183
+ entries[key] = entry;
1184
+ if (pass === 0) log(`${key}: not under a directory this run scans; its pin is kept`);
1185
+ } else log(`${key}: gone, its pin is dropped`);
1186
+ }
1116
1187
  for (const item of items) {
1117
1188
  const recorded = recordedAsIs(item, entries[item.key]);
1118
1189
  if (recorded.same && options.force !== true) {
@@ -1173,8 +1244,31 @@ async function runLocked(settings: SyncSettings, options: SyncRunOptions): Promi
1173
1244
  const replies = "replies" in sent ? sent.replies : [];
1174
1245
  for (let i = 0; i < replies.length; i++) if (replies[i] !== "resp 0") log(`plugin-hostd answered ${replies[i]!} to ${changed[i]!.split(" ")[1]!}`);
1175
1246
  const taken = replies.filter((r) => r === "resp 0").length;
1176
- return { ...count, seconds: (Date.now() - started) / 1000, ...(taken === 0 ? {} : { sent: taken }) };
1247
+ return {
1248
+ ...count,
1249
+ seconds: (Date.now() - started) / 1000,
1250
+ ...(taken === 0 ? {} : { sent: taken }),
1251
+ ...(fresh.refreshed.length === 0 ? {} : { refreshed: fresh.refreshed.length }),
1252
+ };
1253
+ }
1254
+ }
1255
+
1256
+ /** The end of a `--refresh-pins` run: the pin file from the refreshed state, and its changed pins to a running daemon. */
1257
+ async function sendRefreshed(settings: SyncSettings, state: SyncState, refreshed: number, started: number, log: (line: string) => void): Promise<SyncSummary> {
1258
+ let before = "";
1259
+ try {
1260
+ before = readFileSync(settings.pinFile, "utf8");
1261
+ } catch {
1262
+ before = "";
1177
1263
  }
1264
+ const after = syncPinsText(state);
1265
+ if (after !== before) writeFileWhole(settings.pinFile, after);
1266
+ const changed = changedPinLines(before, after);
1267
+ const sent = await sendPins(settings.hostdPort, settings.hostdFeedbackPort, changed);
1268
+ const taken = "replies" in sent ? sent.replies.filter((r) => r === "resp 0").length : 0;
1269
+ if ("unsent" in sent && changed.length > 0) log(`the ${String(changed.length)} changed pins apply at plugin-hostd's next start, from the pin file`);
1270
+ const zero = { qualified: 0, skipped: 0, refused: 0, crashed: 0, hung: 0, unmeasured: 0, distributed: 0 };
1271
+ return { ...zero, seconds: (Date.now() - started) / 1000, ...(taken === 0 ? {} : { sent: taken }), ...(refreshed === 0 ? {} : { refreshed }) };
1178
1272
  }
1179
1273
 
1180
1274
  /** What a failed descriptor pass says: plainly, once, when the lilv binding is what is missing (a suite without python3-lilv). */
@@ -17,6 +17,7 @@ OPERATIONAL_RATE_FLOOR = 48000
17
17
  QUALIFY_RATES = (44100, 48000, 96000, 192000)
18
18
  QUALIFY_QUANTA = (128, 256, 512, 1024)
19
19
  PLUGIN_TIMEOUT_S = 15
20
+ ADDRESS_SPACE_LIMIT_RAM_FRACTION = 1
20
21
  BOOT_TIMEOUT_S = 120
21
22
  COST_TIMEOUT_S = 60
22
23
  DESCRIPTOR_DIR_KEY = "descriptor_dir"
@@ -42,6 +42,7 @@ Usage:
42
42
  import argparse
43
43
  import json
44
44
  import os
45
+ import resource
45
46
  import select
46
47
  import shlex
47
48
  import subprocess
@@ -53,10 +54,13 @@ from collections import namedtuple
53
54
  # declarations (with the rate list and timeouts), generated from `src/qualify-declarations.ts` because this script cannot import
54
55
  # a TS module (2026-09-17 declare-derive audit, F8) — see `_declarations.py`'s own header for the
55
56
  # regenerate/verify commands.
56
- from _declarations import (BOOT_TIMEOUT_S, COST_REFERENCE_RATE, COST_TIMEOUT_S,
57
+ from _declarations import (ADDRESS_SPACE_LIMIT_RAM_FRACTION, BOOT_TIMEOUT_S, COST_REFERENCE_RATE, COST_TIMEOUT_S,
57
58
  MEASURE_BASE_RATE, OPERATIONAL_RATE_FLOOR, PLUGIN_TIMEOUT_S,
58
59
  QUALIFY_RATES, QUANTUM_FRAMES)
59
60
 
61
+ # the outcome of a worker past its address space, the same word as a direct spawn of plugin-qualify's (src/direct-limit.ts)
62
+ MEMORY_LIMIT_EXCEEDED = "memory-limit-exceeded"
63
+
60
64
  try: # numpy is only needed by the worker (and as a fast path in the pure helpers)
61
65
  import numpy as _np
62
66
  except ImportError: # pragma: no cover - CI runs the pure tests without numpy
@@ -172,7 +176,7 @@ COST_TONE_AMP = 0.5
172
176
  # whose tail is genuinely fat — the one that xruns mid-show — while being a stable
173
177
  # statistic at this sample count.
174
178
  COST_PERCENTILE = 95.0
175
- # Cost is anchored at the rig's canonical rate: the headline figure the operator asked for is
179
+ # Cost is anchored at the rig's canonical rate: the headline figure asked for is
176
180
  # "how many of these fit in one core at the rig's rate". `COST_REFERENCE_RATE` is imported above
177
181
  # from `_declarations.py` — the same fact `cpu-cost.ts` declares, not a second 96000 literal.
178
182
  # A cost probe runs many more blocks than a latency probe; give it room.
@@ -1908,6 +1912,8 @@ def worker_main(rate, block, measure=False, cost=False, cpu=None,
1908
1912
  host, uri, declared, res["measuredFrames"], job["sweep"])
1909
1913
  if swept:
1910
1914
  res["sweep"] = swept
1915
+ except MemoryError:
1916
+ res = {"error": MEMORY_LIMIT_EXCEEDED}
1911
1917
  except Exception as e: # noqa: BLE001 - report, don't die
1912
1918
  res = {"error": f"probe error: {e}"}
1913
1919
  if "measuredFrames" in res:
@@ -1924,6 +1930,8 @@ def worker_main(rate, block, measure=False, cost=False, cpu=None,
1924
1930
  symbol = arg
1925
1931
  try:
1926
1932
  frames, reason = probe_one(host, uri, symbol)
1933
+ except MemoryError:
1934
+ frames, reason = None, MEMORY_LIMIT_EXCEEDED
1927
1935
  except Exception as e: # noqa: BLE001 - report, don't die, on python-level errors
1928
1936
  frames, reason = None, f"probe error: {e}"
1929
1937
  if frames is None:
@@ -1935,6 +1943,20 @@ def worker_main(rate, block, measure=False, cost=False, cpu=None,
1935
1943
  # --- the parent: batch loop with crash containment -----------------------------
1936
1944
 
1937
1945
 
1946
+ def address_space_limit(ram_bytes=None):
1947
+ """What the worker runs under: RLIMIT_AS at ADDRESS_SPACE_LIMIT_RAM_FRACTION of the machine's RAM, since a direct
1948
+ spawn has no runner to give it a cgroup; set between fork and exec. A plugin past it fails its allocation, which the
1949
+ worker reports as `MEMORY_LIMIT_EXCEEDED` when Python sees it and which crash containment takes when the plugin dies."""
1950
+ if ram_bytes is None:
1951
+ ram_bytes = os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES")
1952
+ limit = int(ram_bytes * ADDRESS_SPACE_LIMIT_RAM_FRACTION)
1953
+
1954
+ def apply():
1955
+ resource.setrlimit(resource.RLIMIT_AS, (limit, limit))
1956
+
1957
+ return apply
1958
+
1959
+
1938
1960
  class _Worker:
1939
1961
  """Handle on one worker subprocess: spawn, job/reply exchange, kill."""
1940
1962
 
@@ -1942,6 +1964,7 @@ class _Worker:
1942
1964
  self.proc = subprocess.Popen(
1943
1965
  cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
1944
1966
  stderr=subprocess.DEVNULL, text=True, bufsize=1,
1967
+ preexec_fn=address_space_limit(),
1945
1968
  )
1946
1969
  ready = self._read_line(boot_timeout)
1947
1970
  parsed = parse_worker_line(ready)
@@ -33,6 +33,7 @@ const PY_NAMES = [
33
33
  'QUALIFY_RATES',
34
34
  'QUALIFY_QUANTA',
35
35
  'PLUGIN_TIMEOUT_S',
36
+ 'ADDRESS_SPACE_LIMIT_RAM_FRACTION',
36
37
  'BOOT_TIMEOUT_S',
37
38
  'COST_TIMEOUT_S',
38
39
  'DESCRIPTOR_DIR_KEY',
@@ -60,6 +60,7 @@ import {
60
60
  urisFromScan,
61
61
  } from "../dist/measurement/index.js";
62
62
  import { LV2_HOSTING_TIMEOUT_S, LV2_MEASURE_TOOL_NAME, RT_INTERPOSE_NAME } from "../dist/qualify-declarations.js";
63
+ import { addressSpaceLimited, directOutcome } from "../dist/direct-limit.js";
63
64
  import { halfPopulatedRefusal, locateNativeTools } from "../dist/tool-paths.js";
64
65
 
65
66
  const HERE = dirname(fileURLToPath(import.meta.url));
@@ -135,9 +136,10 @@ function runHosting(step, uris, onProgress) {
135
136
  const lines = [];
136
137
  onProgress(step, { kind: "phase", plugins: uris.length, rate: Number(step.args[0]) });
137
138
  for (const uri of uris) {
138
- const r = spawnSync(step.command, [uri, ...step.args], {
139
+ const limited = addressSpaceLimited(step.command, [uri, ...step.args]);
140
+ const r = spawnSync(limited.command, [...limited.args], {
139
141
  encoding: "utf8",
140
- env: { ...process.env, ...step.env },
142
+ env: { ...process.env, ...step.env, ...limited.env },
141
143
  stdio: ["ignore", "pipe", "inherit"],
142
144
  timeout: LV2_HOSTING_TIMEOUT_S * 1000,
143
145
  maxBuffer: 16 << 20,
@@ -149,9 +151,10 @@ function runHosting(step, uris, onProgress) {
149
151
  ...(r.error?.code === "ETIMEDOUT" ? { timedOut: true } : {}),
150
152
  stdout: r.stdout ?? "",
151
153
  });
152
- process.stderr.write(` ${uri}: exit ${line.exit}\n`);
154
+ const limitHit = directOutcome(r.status, limited);
155
+ process.stderr.write(` ${uri}: exit ${line.exit}${limitHit === undefined ? "" : ` (${limitHit.outcome})`}\n`);
153
156
  onProgress(step, { kind: "plugin" });
154
- lines.push(line);
157
+ lines.push(limitHit === undefined ? line : { ...line, ...limitHit });
155
158
  }
156
159
  writeFileSync(step.produces, lines.map((l) => `${JSON.stringify(l)}\n`).join(""));
157
160
  return lines;
@@ -16,7 +16,7 @@ body, and judges it against the plugin's own descriptor params:
16
16
  listed — refuse or report, never drop.
17
17
 
18
18
  Only the bundles lilv loads from LV2_PATH are read, which is what the scan reads: the presets a
19
- plugin ships, never one another host saved in a user's home (ruling 2). The output is sorted by
19
+ plugin ships, never one another host saved in a user's home (decision 2). The output is sorted by
20
20
  preset URI and the values by symbol, so the same install yields the same bytes.
21
21
  """
22
22
 
@@ -40,7 +40,8 @@ async function connect() {
40
40
  async function main() {
41
41
  if ((await Promise.race([spawnFailed, sleep(200).then(() => undefined)])) === "ENOENT") return { installed: false, steps: [] };
42
42
  const sock = await connect();
43
- if (sock === undefined) return { installed: true, steps: [{ cmd: "add", died: hostEnd ?? "closed" }] };
43
+ // the host never listened (omx-clap-host with no JACK server): the plugin was never loaded
44
+ if (sock === undefined) return { installed: true, started: false, steps: [] };
44
45
  let buffer = "";
45
46
  let closed = false;
46
47
  sock.on("data", (d) => (buffer += d.toString()));